This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,91 @@
<?php
/**
* Shared preset arrays for the Create AI Podcast page.
*
* Values mirror what `dashboard/create/presets.ts` exported before the page
* moved to this stack. PHP renders the form's <select> options from these
* arrays; the JS island receives the same arrays via wp_localize_script so
* status notices can label runs by preset id.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast;
/**
* Date-range presets for the "from a window" source mode.
*
* @return array<int, array{id:string,label:string,unit:string,n:int}>
*/
function window_presets(): array {
return array(
array(
'id' => 'last-7-days',
'label' => __( 'Last 7 days', 'jetpack-podcast' ),
'unit' => 'days',
'n' => 7,
),
array(
'id' => 'last-14-days',
'label' => __( 'Last 14 days', 'jetpack-podcast' ),
'unit' => 'days',
'n' => 14,
),
array(
'id' => 'last-30-days',
'label' => __( 'Last 30 days', 'jetpack-podcast' ),
'unit' => 'days',
'n' => 30,
),
array(
'id' => 'last-3-months',
'label' => __( 'Last 3 months', 'jetpack-podcast' ),
'unit' => 'months',
'n' => 3,
),
);
}
/**
* Episode-length presets.
*
* @return array<int, array{id:string,label:string}>
*/
function length_presets(): array {
return array(
array(
'id' => 'short',
'label' => __( 'Short (~3 min)', 'jetpack-podcast' ),
),
array(
'id' => 'medium',
'label' => __( 'Medium (~7 min)', 'jetpack-podcast' ),
),
array(
'id' => 'long',
'label' => __( 'Long (~12 min)', 'jetpack-podcast' ),
),
);
}
/**
* Voice-style presets.
*
* @return array<int, array{id:string,label:string}>
*/
function voice_presets(): array {
return array(
array(
'id' => 'witty',
'label' => __( 'Witty', 'jetpack-podcast' ),
),
array(
'id' => 'earnest',
'label' => __( 'Earnest', 'jetpack-podcast' ),
),
array(
'id' => 'professional',
'label' => __( 'Professional', 'jetpack-podcast' ),
),
);
}
@@ -0,0 +1,896 @@
/*!
* Create AI Podcast — page styles.
*
* Card-based layout aligned with wp-admin's color palette. Scoped under
* .jetpack-create-ai-podcast. Inherits wp-admin form styles for native
* controls (select, button, etc.); custom rules add the card surfaces,
* section headers, credits readout, posts checklist, status notice, and
* disabled-form state.
*/
.jetpack-create-ai-podcast {
max-width: 720px;
}
.jetpack-create-ai-podcast__page-title {
margin: 0 0 20px;
}
/* --- Intro banner -------------------------------------------------------- */
.jetpack-create-ai-podcast__intro {
position: relative;
display: flex;
gap: 24px;
align-items: center;
justify-content: space-between;
padding: 24px 28px;
margin: 0 0 20px;
border-radius: 14px;
color: #fff;
background:
radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.2), transparent 55%),
linear-gradient(135deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
box-shadow:
0 10px 24px rgba(34, 113, 177, 0.25),
0 2px 4px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.jetpack-create-ai-podcast__intro::after {
content: "";
position: absolute;
right: -40px;
bottom: -60px;
width: 200px;
height: 200px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.07);
pointer-events: none;
}
.jetpack-create-ai-podcast__intro-body {
position: relative;
z-index: 1;
max-width: 540px;
}
.jetpack-create-ai-podcast__intro-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 0 0 8px;
}
.jetpack-create-ai-podcast__intro-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #fff;
}
.jetpack-create-ai-podcast__intro-eyebrow--experimental {
background: rgba(255, 196, 80, 0.28);
color: #ffe6a8;
}
.jetpack-create-ai-podcast__intro-wpmark {
flex: none;
display: inline-block;
width: 14px;
height: 14px;
background-color: currentColor;
mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgY2xhc3M9ImdyaWRpY29uIGdyaWRpY29ucy1teS1zaXRlcyIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTIgMkM2LjQ3NyAyIDIgNi40NzcgMiAxMnM0LjQ3NyAxMCAxMCAxMCAxMC00LjQ3NyAxMC0xMFMxNy41MjMgMiAxMiAyTTMuNSAxMmMwLTEuMjMyLjI2NC0yLjQwMi43MzYtMy40NTlMOC4yOTEgMTkuNjVBOC41IDguNSAwIDAgMSAzLjUgMTJtOC41IDguNTAxYy0uODM0IDAtMS42NC0uMTIyLTIuNDAxLS4zNDZsMi41NTEtNy40MTEgMi42MTMgNy4xNThhMSAxIDAgMCAwIC4wNjEuMTE3IDguNSA4LjUgMCAwIDEtMi44MjQuNDgybTEuMTcyLTEyLjQ4NmMuNTEyLS4wMjcuOTczLS4wODEuOTczLS4wODEuNDU4LS4wNTQuNDA0LS43MjctLjA1NC0uNzAxIDAgMC0xLjM3Ny4xMDgtMi4yNjYuMTA4LS44MzUgMC0yLjIzOS0uMTA4LTIuMjM5LS4xMDgtLjQ1OS0uMDI2LS41MTIuNjc0LS4wNTQuNzAxIDAgMCAuNDM0LjA1NC44OTIuMDgxbDEuMzI0IDMuNjI5LTEuODYgNS41NzktMy4wOTYtOS4yMDhjLjUxMi0uMDI3Ljk3My0uMDgxLjk3My0uMDgxLjQ1OC0uMDU0LjQwMy0uNzI3LS4wNTUtLjcwMSAwIDAtMS4zNzYuMTA4LTIuMjY1LjEwOC0uMTYgMC0uMzQ3LS4wMDQtLjU0Ny0uMDFBOC40OSA4LjQ5IDAgMCAxIDEyIDMuNWMyLjIxMyAwIDQuMjI4Ljg0NiA1Ljc0IDIuMjMyLS4wMzctLjAwMi0uMDcyLS4wMDctLjExLS4wMDctLjgzNSAwLTEuNDI3LjcyNy0xLjQyNyAxLjUwOSAwIC43MDEuNDA0IDEuMjkzLjgzNSAxLjk5NC4zMjMuNTY2LjcwMSAxLjI5My43MDEgMi4zNDQgMCAuNzI3LS4yOCAxLjU3Mi0uNjQ3IDIuNzQ4bC0uODQ4IDIuODMzem0zLjEwMSAxMS4zMzIgMi41OTYtNy41MDZjLjQ4NS0xLjIxMy42NDYtMi4xODIuNjQ2LTMuMDQ1cS0uMDAyLS40NjktLjA1Ny0uODc0QTguNDYgOC40NiAwIDAgMSAyMC41IDEyYTguNDkgOC40OSAwIDAgMS00LjIyNyA3LjM0NyIvPjwvc3ZnPg==);
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
.jetpack-create-ai-podcast__intro-title {
margin: 0 0 8px;
font-size: 22px;
line-height: 1.25;
font-weight: 600;
color: #fff;
}
.jetpack-create-ai-podcast__intro-text {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.92);
}
.jetpack-create-ai-podcast__intro-art {
position: relative;
z-index: 1;
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(4px);
}
/* --- Cards ---------------------------------------------------------------- */
.jetpack-create-ai-podcast__card {
background: #fff;
border: 1px solid #dcdcde;
border-radius: 8px;
padding: 20px 24px;
margin: 0 0 16px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
}
.jetpack-create-ai-podcast__card--accent {
border-left: 4px solid #2271b1;
padding-left: 20px;
}
.jetpack-create-ai-podcast__card-title {
margin: 0 0 16px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #50575e;
}
/* --- Credits readout ------------------------------------------------------ */
.jetpack-create-ai-podcast__credits {
font-size: 14px;
color: #1d2327;
}
.jetpack-create-ai-podcast__credits[data-state="hidden"] {
display: none;
}
.jetpack-create-ai-podcast__credits[data-state="not-available"] {
border-left: 4px solid #d63638;
padding-left: 20px;
}
.jetpack-create-ai-podcast__credits[data-state="not-available"] p {
margin: 0;
}
.jetpack-create-ai-podcast__credits-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.jetpack-create-ai-podcast__credits-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #50575e;
}
.jetpack-create-ai-podcast__credits-count {
font-size: 18px;
font-weight: 600;
color: #1d2327;
font-variant-numeric: tabular-nums;
}
.jetpack-create-ai-podcast__credits-bar {
height: 8px;
border-radius: 999px;
background: #f0f0f1;
overflow: hidden;
}
.jetpack-create-ai-podcast__credits-fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
border-radius: 999px;
transition: width 320ms cubic-bezier(0.2, 0.8, 0.2, 1), background 200ms ease;
}
.jetpack-create-ai-podcast__credits-fill[data-tone="warning"] {
background: linear-gradient(90deg, #dba617, #a47800);
}
.jetpack-create-ai-podcast__credits-fill[data-tone="danger"] {
background: linear-gradient(90deg, #d63638, #b32d2e);
}
.jetpack-create-ai-podcast__credits-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin-top: 10px;
font-size: 12px;
color: #646970;
}
.jetpack-create-ai-podcast__credits-meta-sep {
color: #c3c4c7;
}
.jetpack-create-ai-podcast__credits-meta-reset {
font-weight: 500;
color: #50575e;
}
.jetpack-create-ai-podcast__credits-banner {
display: flex;
align-items: center;
gap: 14px;
margin-top: 16px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid transparent;
background: #fff;
flex-wrap: wrap;
}
.jetpack-create-ai-podcast__credits-banner[data-state="low"] {
border-color: rgba(219, 166, 23, 0.32);
background: rgba(219, 166, 23, 0.08);
}
.jetpack-create-ai-podcast__credits-banner[data-state="out"] {
border-color: rgba(214, 54, 56, 0.32);
background: rgba(214, 54, 56, 0.08);
}
.jetpack-create-ai-podcast__credits-banner-body {
flex: 1 1 240px;
min-width: 0;
}
.jetpack-create-ai-podcast__credits-banner-title {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
color: #1d2327;
}
.jetpack-create-ai-podcast__credits-banner-message {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #50575e;
}
.jetpack-create-ai-podcast__credits-banner-cta {
flex: none;
}
@media (prefers-reduced-motion: reduce) {
.jetpack-create-ai-podcast__credits-fill {
transition: none;
}
}
/* --- Form fields ---------------------------------------------------------- */
.jetpack-create-ai-podcast__field {
margin: 0 0 16px;
}
.jetpack-create-ai-podcast__field:last-child {
margin-bottom: 0;
}
.jetpack-create-ai-podcast__field label {
display: block;
margin: 0 0 6px;
font-weight: 600;
font-size: 14px;
color: #1d2327;
}
.jetpack-create-ai-podcast__field select,
.jetpack-create-ai-podcast__field input[type="search"],
.jetpack-create-ai-podcast__field textarea {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
/* --- Radio group ---------------------------------------------------------- */
.jetpack-create-ai-podcast__radio-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 20px;
}
.jetpack-create-ai-podcast__radio {
flex: 1 1 200px;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: 1px solid #dcdcde;
border-radius: 6px;
background: #f6f7f7;
cursor: pointer;
font-size: 14px;
color: #1d2327;
transition: border-color 120ms ease, background-color 120ms ease;
}
.jetpack-create-ai-podcast__radio:hover {
border-color: #2271b1;
background: #fff;
}
.jetpack-create-ai-podcast__radio:has(input:checked) {
border-color: #2271b1;
background: #fff;
box-shadow: inset 0 0 0 1px #2271b1;
}
.jetpack-create-ai-podcast__radio input[type="radio"] {
margin: 0;
}
.jetpack-create-ai-podcast__field-hint {
margin: 4px 0 0;
font-size: 12px;
color: #646970;
}
/* --- Posts picker --------------------------------------------------------- */
.jetpack-create-ai-podcast__posts {
margin-top: 8px;
max-height: 320px;
overflow-y: auto;
border: 1px solid #dcdcde;
border-radius: 6px;
background: #fff;
padding: 8px 12px;
}
.jetpack-create-ai-podcast__posts ul {
margin: 0;
padding: 0;
list-style: none;
}
.jetpack-create-ai-podcast__posts li {
margin: 0;
padding: 6px 0;
border-bottom: 1px solid #f0f0f1;
}
.jetpack-create-ai-podcast__posts li:last-child {
border-bottom: 0;
}
.jetpack-create-ai-podcast__posts label {
font-weight: 400;
margin: 0;
display: flex;
gap: 8px;
align-items: baseline;
font-size: 14px;
color: #1d2327;
cursor: pointer;
}
.jetpack-create-ai-podcast__posts .date {
color: #646970;
font-size: 13px;
margin-left: auto;
}
.jetpack-create-ai-podcast__posts-loading {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
padding: 12px 0;
color: #646970;
font-size: 14px;
}
.jetpack-create-ai-podcast__posts-loading::before {
content: "";
width: 14px;
height: 14px;
border: 2px solid #dcdcde;
border-top-color: #2271b1;
border-radius: 50%;
animation: jetpack-create-ai-podcast-spin 800ms linear infinite;
}
@keyframes jetpack-create-ai-podcast-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.jetpack-create-ai-podcast__posts-loading::before,
.jetpack-create-ai-podcast__status-spinner {
animation: none;
}
}
/* --- Actions row ---------------------------------------------------------- */
.jetpack-create-ai-podcast__advanced {
margin-top: 20px;
padding: 16px 18px;
border: 1px dashed #dcdcde;
border-radius: 10px;
background: #fafafb;
opacity: 0.95;
}
.jetpack-create-ai-podcast__advanced-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.jetpack-create-ai-podcast__advanced-title {
margin: 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #50575e;
}
.jetpack-create-ai-podcast__soon-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
background: linear-gradient(135deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
color: #fff;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.jetpack-create-ai-podcast__advanced .jetpack-create-ai-podcast__field label {
color: #646970;
}
.jetpack-create-ai-podcast__advanced :is(select, textarea, input):disabled {
background-color: #fff;
color: #8c8f94;
cursor: not-allowed;
}
.jetpack-create-ai-podcast__actions {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f1;
display: flex;
gap: 12px;
}
/* --- Disabled form state -------------------------------------------------- */
.jetpack-create-ai-podcast__form[data-disabled="true"] {
opacity: 0.6;
pointer-events: none;
}
/* --- Status notice (floating toast) -------------------------------------- */
.jetpack-create-ai-podcast__status {
position: fixed;
top: 56px;
right: 24px;
z-index: 100000;
max-width: calc(100vw - 48px);
width: 360px;
pointer-events: none;
}
.jetpack-create-ai-podcast__status[data-state="idle"] {
display: none;
}
.jetpack-create-ai-podcast__status-card {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
padding: 14px 16px 14px 20px;
box-shadow:
0 12px 32px rgba(15, 23, 42, 0.12),
0 2px 6px rgba(15, 23, 42, 0.06);
pointer-events: auto;
animation: jetpack-create-ai-podcast-toast-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
overflow: hidden;
}
.jetpack-create-ai-podcast__status-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: #2271b1;
}
.jetpack-create-ai-podcast__status-card[data-tone="success"]::before {
background: #00a32a;
}
.jetpack-create-ai-podcast__status-card[data-tone="error"]::before {
background: #d63638;
}
.jetpack-create-ai-podcast__status-spinner {
flex: none;
display: inline-block;
width: 16px;
height: 16px;
margin-top: 2px;
border: 2px solid rgba(34, 113, 177, 0.25);
border-top-color: #2271b1;
border-radius: 50%;
animation: jetpack-create-ai-podcast-spin 800ms linear infinite;
}
.jetpack-create-ai-podcast__status-body {
flex: 1;
min-width: 0;
}
.jetpack-create-ai-podcast__status-message {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #1d2327;
font-weight: 600;
}
.jetpack-create-ai-podcast__status-subtext {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: #50575e;
}
.jetpack-create-ai-podcast__status-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-top: 10px;
}
.jetpack-create-ai-podcast__status-link {
text-decoration: underline;
font-size: 13px;
}
.jetpack-create-ai-podcast__status-dismiss {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: -4px -4px 0 0;
padding: 0;
background: transparent;
border: 0;
border-radius: 6px;
color: #50575e;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.jetpack-create-ai-podcast__status-dismiss:hover,
.jetpack-create-ai-podcast__status-dismiss:focus-visible {
background: rgba(15, 23, 42, 0.06);
color: #1d2327;
}
.jetpack-create-ai-podcast__status-dismiss:focus-visible {
outline: 2px solid #2271b1;
outline-offset: 2px;
}
@keyframes jetpack-create-ai-podcast-toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.jetpack-create-ai-podcast__status-card {
animation: none;
}
}
/* --- Generated episodes list --------------------------------------------- */
.jetpack-create-ai-podcast__episodes-empty {
margin: 0;
color: #50575e;
font-size: 13px;
}
.jetpack-create-ai-podcast__episodes-items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.jetpack-create-ai-podcast__episode {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
background: #fff;
border: 1px solid #dcdcde;
border-radius: 8px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
}
.jetpack-create-ai-podcast__episode-header {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
}
.jetpack-create-ai-podcast__episode-title {
font-size: 14px;
font-weight: 600;
color: #1d2327;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jetpack-create-ai-podcast__episode-status {
flex: none;
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: #f0f0f1;
color: #50575e;
}
.jetpack-create-ai-podcast__episode-status[data-status="publish"] {
background: rgba(0, 163, 42, 0.12);
color: #0a6b1c;
}
.jetpack-create-ai-podcast__episode-player {
width: 100%;
display: block;
}
.jetpack-create-ai-podcast__episode-actions {
display: flex;
gap: 12px;
}
.jetpack-create-ai-podcast__episode-edit {
text-decoration: underline;
font-size: 13px;
}
/* Pagination */
.jetpack-create-ai-podcast__pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #f0f0f1;
}
.jetpack-create-ai-podcast__pagination-summary {
font-size: 12px;
color: #646970;
}
.jetpack-create-ai-podcast__pagination-controls {
display: inline-flex;
align-items: center;
gap: 4px;
}
.jetpack-create-ai-podcast__pagination-button {
min-width: 32px;
height: 32px;
padding: 0 10px;
border: 1px solid #dcdcde;
border-radius: 6px;
background: #fff;
color: #1d2327;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
.jetpack-create-ai-podcast__pagination-button:hover:not(:disabled) {
border-color: #2271b1;
color: #2271b1;
}
.jetpack-create-ai-podcast__pagination-button:focus-visible {
outline: 2px solid #2271b1;
outline-offset: 2px;
}
.jetpack-create-ai-podcast__pagination-button:disabled {
color: #a7aaad;
background: #f6f7f7;
cursor: not-allowed;
}
.jetpack-create-ai-podcast__pagination-button[aria-current="page"] {
background: #1d2327;
border-color: #1d2327;
color: #fff;
cursor: default;
}
.jetpack-create-ai-podcast__pagination-button--prev,
.jetpack-create-ai-podcast__pagination-button--next {
font-weight: 600;
}
.jetpack-create-ai-podcast__pagination-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
color: #8c8f94;
font-size: 13px;
}
/* Loading skeleton */
.jetpack-create-ai-podcast__episodes-items--skeleton .jetpack-create-ai-podcast__episode {
pointer-events: none;
}
.jetpack-create-ai-podcast__skeleton {
display: block;
border-radius: 6px;
background: linear-gradient(90deg, #eceef0 0%, #f6f7f7 50%, #eceef0 100%);
background-size: 200% 100%;
animation: jetpack-create-ai-podcast-shimmer 1.4s ease-in-out infinite;
}
.jetpack-create-ai-podcast__skeleton--title {
height: 14px;
width: 60%;
max-width: 280px;
}
.jetpack-create-ai-podcast__skeleton--pill {
height: 18px;
width: 64px;
border-radius: 999px;
}
.jetpack-create-ai-podcast__skeleton--player {
height: 42px;
width: 100%;
}
.jetpack-create-ai-podcast__skeleton--link {
height: 12px;
width: 80px;
}
@keyframes jetpack-create-ai-podcast-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.jetpack-create-ai-podcast__skeleton {
animation: none;
}
}
/* --- Narrow viewport tweaks ---------------------------------------------- */
@media (max-width: 600px) {
.jetpack-create-ai-podcast {
max-width: none;
}
.jetpack-create-ai-podcast__card {
padding: 16px;
}
.jetpack-create-ai-podcast__radio {
flex: 1 1 100%;
}
.jetpack-create-ai-podcast__actions .button-hero {
flex: 1;
}
.jetpack-create-ai-podcast__status {
top: 12px;
right: 12px;
left: 12px;
width: auto;
max-width: none;
}
.jetpack-create-ai-podcast__intro {
flex-direction: column;
align-items: flex-start;
padding: 20px;
}
.jetpack-create-ai-podcast__intro-art {
width: 72px;
height: 72px;
}
}
@@ -0,0 +1,116 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "jetpack/podcast-episode",
"title": "Podcast Episode",
"description": "Embed a single podcast episode from an audio or video file, with Podcasting 2.0 metadata.",
"keywords": [ "audio", "podcast", "episode" ],
"version": "1.0.0",
"textdomain": "jetpack-podcast",
"category": "embed",
"icon": "<svg viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'><path d='M12 2a5 5 0 0 0-5 5v5a5 5 0 0 0 10 0V7a5 5 0 0 0-5-5zm0 2a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V7a3 3 0 0 1 3-3zm-7 8a1 1 0 0 1 1 1 6 6 0 0 0 12 0 1 1 0 1 1 2 0 8 8 0 0 1-7 7.93V22h3v2H8v-2h3v-1.07A8 8 0 0 1 4 13a1 1 0 0 1 1-1z'/></svg>",
"usesContext": [ "postId", "postType" ],
"supports": {
"spacing": {
"padding": true,
"margin": true
},
"anchor": true,
"customClassName": true,
"className": true,
"html": false,
"multiple": true,
"reusable": true
},
"attributes": {
"mediaId": {
"type": "integer"
},
"mediaUrl": {
"type": "string"
},
"mediaType": {
"type": "string",
"enum": [ "audio", "video" ]
},
"mediaMimeType": {
"type": "string"
},
"episodeNumber": {
"type": "integer"
},
"seasonNumber": {
"type": "integer"
},
"episodeType": {
"type": "string",
"enum": [ "full", "trailer", "bonus" ],
"default": "full"
},
"explicit": {
"type": "boolean",
"default": false
},
"duration": {
"type": "string",
"default": ""
},
"transcriptUrl": {
"type": "string",
"default": ""
},
"transcriptType": {
"type": "string",
"enum": [ "text/vtt", "text/html", "application/srt", "application/json" ],
"default": "text/vtt"
},
"chaptersUrl": {
"type": "string",
"default": ""
},
"chaptersType": {
"type": "string",
"default": "application/json+chapters"
},
"locationName": {
"type": "string",
"default": ""
},
"license": {
"type": "string",
"default": ""
},
"licenseUrl": {
"type": "string",
"default": ""
},
"people": {
"type": "array",
"default": []
},
"showPoster": {
"type": "boolean",
"default": true
},
"coverArt": {
"type": "object",
"default": {}
},
"soundbites": {
"type": "array",
"default": []
},
"alternateEnclosures": {
"type": "array",
"default": []
}
},
"example": {
"attributes": {
"episodeNumber": 1,
"seasonNumber": 1,
"episodeType": "full",
"duration": "11:25"
}
}
}
@@ -0,0 +1,760 @@
<?php
/**
* Podcast Episode block.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Blocks;
use Automattic\Jetpack\Status\Request;
/**
* Registers and renders the Podcast Episode block.
*
* The caller (Podcast::init()) is responsible for the host gate.
*/
class Podcast_Episode_Block {
/**
* Editor script handle.
*/
const EDITOR_HANDLE = 'jetpack-podcast-episode-editor';
/**
* Front-end + editor shared style handle. Side-loaded by
* `Assets::register_script` from the sibling `style.css` bundle.
*/
const STYLE_HANDLE = 'jetpack-podcast-episode-style';
/**
* Front-end view script handle. Enqueued from the render callback
* because the block.json `viewScript` field can't resolve to the
* package's dist directory from the deployed src location.
*/
const VIEW_HANDLE = 'jetpack-podcast-episode-view';
/**
* Wire the block's actions.
*/
public static function register_hooks() {
add_action( 'init', array( __CLASS__, 'register_block' ), 9 );
add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'load_editor_scripts' ), 9 );
}
/**
* Register the block.
*
* Also registers the front-end style bundle (built separately from the
* editor bundle so it actually ships on the public post page) and hands
* the handle to `register_block_type` via the `style` arg, which auto-
* enqueues it whenever the block is rendered.
*/
public static function register_block() {
// Assets::register_script side-loads the sibling style.css and
// registers a style handle under the same name. The accompanying
// (essentially empty) style.js handle is registered too but never
// enqueued — only the style is passed to register_block_type below.
Assets::register_script(
self::STYLE_HANDLE,
'../../../dist/blocks/podcast-episode/style.js',
__FILE__,
array(
'css_path' => '../../../dist/blocks/podcast-episode/style.css',
)
);
Blocks::jetpack_register_block(
__DIR__,
array(
'render_callback' => array( __CLASS__, 'render_block' ),
'style' => self::STYLE_HANDLE,
'render_email_callback' => array( __CLASS__, 'render_email' ),
)
);
}
/**
* Register and enqueue the front-end view script that wires chapter and
* soundbite buttons to the audio player. Called from the render callback
* so the script only ships on pages that actually contain the block.
*
* `Assets::register_script` dedups internally, so calling this for each
* rendered instance of the block is safe.
*/
private static function enqueue_view_script() {
Assets::register_script(
self::VIEW_HANDLE,
'../../../dist/blocks/podcast-episode/view.js',
__FILE__,
array(
'in_footer' => true,
'enqueue' => true,
)
);
}
/**
* Enqueue the bundled editor script + style from the package's dist/.
*/
public static function load_editor_scripts() {
Assets::register_script(
self::EDITOR_HANDLE,
'../../../dist/blocks/podcast-episode/editor.js',
__FILE__,
array(
'in_footer' => true,
'enqueue' => true,
'textdomain' => 'jetpack-podcast',
)
);
// Add the script_loader_src rewrite only while the editor script is
// in flight, so the filter doesn't run on every front-end script load.
add_filter( 'script_loader_src', array( __CLASS__, 'filter_editor_script_src' ), 10, 2 );
}
/**
* Rewrite the editor script src to match the admin scheme.
*
* On WPCOM sites with a custom domain mapping that lacks SSL, `home_url()`
* (and therefore `plugins_url()`) returns `http://mapped-domain.test` even
* though wp-admin is served from `.wordpress.com` over HTTPS. The script
* URL then trips the browser's mixed-content block. Routing through the
* canonical `script_loader_src` filter keeps the URL valid in both cases
* without mutating `$wp_scripts->registered` directly.
*
* @param string $src Script source URL.
* @param string $handle Script handle.
* @return string
*/
public static function filter_editor_script_src( $src, $handle ) {
if ( self::EDITOR_HANDLE !== $handle ) {
return $src;
}
$admin_scheme = wp_parse_url( admin_url(), PHP_URL_SCHEME );
if ( ! $admin_scheme ) {
return $src;
}
return set_url_scheme( $src, $admin_scheme );
}
/**
* Format a seconds value as a MM:SS or HH:MM:SS label. Negative input clamps to 0.
*
* @param float|int|string $seconds Seconds value (Podcasting 2.0 soundbite startTime/duration are floats).
* @return string Formatted label (always non-empty; "0:00" for zero input).
*/
private static function format_seconds_label( $seconds ) {
$total = (int) floor( max( 0, (float) $seconds ) );
$hours = (int) floor( $total / 3600 );
$minutes = (int) floor( ( $total % 3600 ) / 60 );
$secs = $total % 60;
if ( $hours > 0 ) {
return sprintf( '%d:%02d:%02d', $hours, $minutes, $secs );
}
return sprintf( '%d:%02d', $minutes, $secs );
}
/**
* Resolve the episode cover art URL: episode override → post featured
* image → show-level `podcasting_image` option → empty string.
*
* @param array $attributes Block attributes.
* @param \WP_Post $post Episode post.
* @param string $size Featured-image size to request.
* @return string
*/
private static function resolve_cover_art_url( array $attributes, $post, $size ) {
if ( isset( $attributes['coverArt'] ) && is_array( $attributes['coverArt'] ) && ! empty( $attributes['coverArt']['url'] ) ) {
return esc_url_raw( $attributes['coverArt']['url'] );
}
$featured_id = (int) get_post_thumbnail_id( $post );
if ( $featured_id ) {
$featured_url = (string) wp_get_attachment_image_url( $featured_id, $size );
if ( '' !== $featured_url ) {
return $featured_url;
}
}
return (string) get_option( 'podcasting_image', '' );
}
/**
* Render callback.
*
* Pulls title, author, and date from the surrounding post — the post is
* the episode. Cover art falls back to the show-level `podcasting_image`
* option when the block has no episode-specific override.
*
* @param array $attributes Block attributes.
* @param string $content Inner content (fallback direct-link markup from save.js).
* @param \WP_Block $block The parsed block instance, used to read post context.
* @return string
*/
public static function render_block( $attributes, $content, $block = null ) {
// Outside the frontend, fall back to the saved direct link so RSS / email / REST export stays
// simple and predictable.
if ( ! Request::is_frontend() ) {
return $content;
}
if ( empty( $attributes['mediaUrl'] ) ) {
return '';
}
// Resolve the post that backs this episode. Prefer block context (set by Query Loop / singular
// templates / post-bound block contexts) and fall back to the global loop for direct theme
// rendering. With no resolvable post, the block has nothing to display.
$post_id = 0;
if ( $block && isset( $block->context['postId'] ) ) {
$post_id = (int) $block->context['postId'];
}
if ( ! $post_id ) {
$post_id = (int) get_the_ID();
}
if ( ! $post_id ) {
return '';
}
$post = get_post( $post_id );
if ( ! $post ) {
return '';
}
$media_url = esc_url_raw( $attributes['mediaUrl'] );
if ( ! wp_http_validate_url( $media_url ) ) {
return '';
}
$media_type = isset( $attributes['mediaType'] ) && 'video' === $attributes['mediaType'] ? 'video' : 'audio';
$mime_type = isset( $attributes['mediaMimeType'] ) ? (string) $attributes['mediaMimeType'] : '';
$episode_number = isset( $attributes['episodeNumber'] ) ? (int) $attributes['episodeNumber'] : 0;
$season_number = isset( $attributes['seasonNumber'] ) ? (int) $attributes['seasonNumber'] : 0;
$episode_type = isset( $attributes['episodeType'] ) ? (string) $attributes['episodeType'] : 'full';
$is_explicit = ! empty( $attributes['explicit'] );
$duration = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : '';
$show_poster = ! isset( $attributes['showPoster'] ) || ! empty( $attributes['showPoster'] );
$transcript_url = isset( $attributes['transcriptUrl'] ) ? esc_url_raw( $attributes['transcriptUrl'] ) : '';
$location_name = isset( $attributes['locationName'] ) ? (string) $attributes['locationName'] : '';
$license = isset( $attributes['license'] ) ? (string) $attributes['license'] : '';
$license_url = isset( $attributes['licenseUrl'] ) ? esc_url_raw( $attributes['licenseUrl'] ) : '';
$people = isset( $attributes['people'] ) && is_array( $attributes['people'] ) ? $attributes['people'] : array();
$soundbites = isset( $attributes['soundbites'] ) && is_array( $attributes['soundbites'] ) ? $attributes['soundbites'] : array();
$alternate_enclosures = isset( $attributes['alternateEnclosures'] ) && is_array( $attributes['alternateEnclosures'] ) ? $attributes['alternateEnclosures'] : array();
// Only ship the click-to-seek script on episodes that actually have soundbites to wire.
// Chapters are hosted as an external JSON file and consumed by Podcasting 2.0 players directly.
if ( ! empty( $soundbites ) ) {
self::enqueue_view_script();
}
if ( '' !== $transcript_url && ! wp_http_validate_url( $transcript_url ) ) {
$transcript_url = '';
}
$author_id = (int) $post->post_author;
$title = get_the_title( $post );
$author_name = get_the_author_meta( 'display_name', $author_id );
$author_url = esc_url_raw( (string) get_the_author_meta( 'url', $author_id ) );
$publish_date_iso = get_the_date( 'c', $post );
$publish_date = get_the_date( '', $post );
$episode_url = get_permalink( $post );
$transcript_type = isset( $attributes['transcriptType'] ) ? (string) $attributes['transcriptType'] : '';
// Show-level data backs the `partOfSeries` PodcastSeries reference so
// search engines can connect the episode to its parent show.
$show_title = (string) get_option( 'podcasting_title', '' );
$show_image_url = (string) get_option( 'podcasting_image', '' );
$show_email = (string) get_option( 'podcasting_email', '' );
// Cover art chain resolved unconditionally so schema metadata always carries
// the image; the `$show_poster` toggle only gates the visible figure and the
// video poster.
$image_url = self::resolve_cover_art_url( $attributes, $post, 'full' );
// AudioObject/VideoObject @type for the embedded media.
$media_object_type = 'video' === $media_type ? 'VideoObject' : 'AudioObject';
$wrapper_attributes = get_block_wrapper_attributes();
ob_start();
?>
<div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() returns pre-escaped attribute output. ?>>
<article class="jetpack-podcast-episode" itemscope itemtype="https://schema.org/PodcastEpisode">
<?php if ( $episode_url ) : ?>
<link itemprop="url" href="<?php echo esc_url( $episode_url ); ?>" />
<?php endif; ?>
<?php if ( $image_url && $show_poster ) : ?>
<figure class="jetpack-podcast-episode__poster">
<img
src="<?php echo esc_url( $image_url ); ?>"
alt=""
itemprop="image"
loading="lazy"
/>
</figure>
<?php elseif ( $image_url ) : ?>
<meta itemprop="image" content="<?php echo esc_url( $image_url ); ?>" />
<?php endif; ?>
<div class="jetpack-podcast-episode__body">
<?php if ( $season_number || $episode_number || 'full' !== $episode_type || $is_explicit ) : ?>
<p class="jetpack-podcast-episode__meta-line">
<?php if ( $season_number ) : ?>
<span class="jetpack-podcast-episode__season" itemprop="partOfSeason" itemscope itemtype="https://schema.org/PodcastSeason">
<meta itemprop="seasonNumber" content="<?php echo esc_attr( (string) $season_number ); ?>" />
<?php
/* translators: %d: season number. */
echo esc_html( sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number ) );
?>
</span>
<?php endif; ?>
<?php if ( $episode_number ) : ?>
<span class="jetpack-podcast-episode__episode-number">
<meta itemprop="episodeNumber" content="<?php echo esc_attr( (string) $episode_number ); ?>" />
<?php
/* translators: %d: episode number. */
echo esc_html( sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number ) );
?>
</span>
<?php endif; ?>
<?php if ( 'trailer' === $episode_type ) : ?>
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--trailer"><?php esc_html_e( 'Trailer', 'jetpack-podcast' ); ?></span>
<?php elseif ( 'bonus' === $episode_type ) : ?>
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--bonus"><?php esc_html_e( 'Bonus', 'jetpack-podcast' ); ?></span>
<?php endif; ?>
<?php if ( $is_explicit ) : ?>
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--explicit" title="<?php esc_attr_e( 'Explicit content', 'jetpack-podcast' ); ?>"><?php echo esc_html( _x( 'E', 'short label for explicit content', 'jetpack-podcast' ) ); ?></span>
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ( $title ) : ?>
<h3 class="jetpack-podcast-episode__title" itemprop="name"><?php echo esc_html( $title ); ?></h3>
<?php endif; ?>
<?php if ( $author_name || $publish_date || $duration ) : ?>
<p class="jetpack-podcast-episode__byline">
<?php if ( $author_name ) : ?>
<span class="jetpack-podcast-episode__author" itemprop="author" itemscope itemtype="https://schema.org/Person">
<?php if ( $author_url ) : ?>
<a href="<?php echo esc_url( $author_url ); ?>" itemprop="url">
<span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
</a>
<?php else : ?>
<span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
<?php endif; ?>
</span>
<?php endif; ?>
<?php if ( $publish_date ) : ?>
<time
class="jetpack-podcast-episode__date"
datetime="<?php echo esc_attr( $publish_date_iso ); ?>"
itemprop="datePublished"
>
<?php echo esc_html( $publish_date ); ?>
</time>
<?php endif; ?>
<?php if ( $duration ) : ?>
<span class="jetpack-podcast-episode__duration"><?php echo esc_html( $duration ); ?></span>
<?php endif; ?>
</p>
<?php endif; ?>
<div
class="jetpack-podcast-episode__player"
itemprop="<?php echo 'video' === $media_type ? 'video' : 'audio'; ?>"
itemscope
itemtype="https://schema.org/<?php echo esc_attr( $media_object_type ); ?>"
>
<meta itemprop="contentUrl" content="<?php echo esc_url( $media_url ); ?>" />
<?php if ( $mime_type ) : ?>
<meta itemprop="encodingFormat" content="<?php echo esc_attr( $mime_type ); ?>" />
<?php endif; ?>
<?php if ( $duration ) : ?>
<meta itemprop="duration" content="<?php echo esc_attr( $duration ); ?>" />
<?php endif; ?>
<?php if ( 'video' === $media_type ) : ?>
<video
class="jetpack-podcast-episode__video"
controls
preload="none"
src="<?php echo esc_url( $media_url ); ?>"
<?php
if ( $image_url && $show_poster ) :
?>
poster="<?php echo esc_url( $image_url ); ?>"<?php endif; ?>
<?php
if ( $mime_type ) :
?>
data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
></video>
<?php else : ?>
<audio
class="jetpack-podcast-episode__audio"
controls
preload="none"
src="<?php echo esc_url( $media_url ); ?>"
<?php
if ( $mime_type ) :
?>
data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
></audio>
<?php endif; ?>
</div>
<?php if ( ! empty( $soundbites ) ) : ?>
<ul class="jetpack-podcast-episode__soundbites">
<?php
foreach ( $soundbites as $soundbite ) :
if ( ! is_array( $soundbite ) || ! isset( $soundbite['startTime'] ) ) {
continue;
}
$start_label = self::format_seconds_label( $soundbite['startTime'] );
$soundbite_title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : '';
$start_seconds = (int) floor( max( 0, (float) $soundbite['startTime'] ) );
$end_seconds = isset( $soundbite['duration'] )
? $start_seconds + (int) floor( max( 0, (float) $soundbite['duration'] ) )
: null;
?>
<li
class="jetpack-podcast-episode__soundbite"
itemprop="hasPart"
itemscope
itemtype="https://schema.org/Clip"
>
<meta itemprop="startOffset" content="<?php echo esc_attr( (string) $start_seconds ); ?>" />
<?php if ( null !== $end_seconds ) : ?>
<meta itemprop="endOffset" content="<?php echo esc_attr( (string) $end_seconds ); ?>" />
<?php endif; ?>
<button
type="button"
class="jetpack-podcast-episode__soundbite-button"
data-start-time="<?php echo esc_attr( (string) $start_seconds ); ?>"
>
<time class="jetpack-podcast-episode__soundbite-time"><?php echo esc_html( $start_label ); ?></time>
<?php if ( '' !== $soundbite_title ) : ?>
<span class="jetpack-podcast-episode__soundbite-title" itemprop="name"><?php echo esc_html( $soundbite_title ); ?></span>
<?php endif; ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ( ! empty( $alternate_enclosures ) ) : ?>
<ul class="jetpack-podcast-episode__alternates">
<?php
foreach ( $alternate_enclosures as $alt ) :
if ( ! is_array( $alt ) || empty( $alt['url'] ) ) {
continue;
}
$alt_url = esc_url_raw( (string) $alt['url'] );
if ( ! wp_http_validate_url( $alt_url ) ) {
continue;
}
$alt_type = isset( $alt['type'] ) ? (string) $alt['type'] : '';
$alt_title = isset( $alt['title'] ) ? trim( (string) $alt['title'] ) : '';
$alt_lang = isset( $alt['lang'] ) ? (string) $alt['lang'] : '';
$alt_bitrate = isset( $alt['bitrate'] ) ? (int) $alt['bitrate'] : 0;
$details = array();
if ( '' !== $alt_lang ) {
$details[] = $alt_lang;
}
if ( $alt_bitrate > 0 ) {
$details[] = sprintf(
/* translators: %d: bitrate in kilobits per second. */
__( '%d kbps', 'jetpack-podcast' ),
(int) round( $alt_bitrate / 1000 )
);
}
if ( '' !== $alt_type ) {
$details[] = $alt_type;
}
$details_label = $details ? ' (' . implode( ', ', $details ) . ')' : '';
$display_label = '' !== $alt_title ? $alt_title : __( 'Alternative version', 'jetpack-podcast' );
?>
<li class="jetpack-podcast-episode__alternate">
<a href="<?php echo esc_url( $alt_url ); ?>"<?php echo '' !== $alt_lang ? ' hreflang="' . esc_attr( $alt_lang ) . '"' : ''; ?>>
<?php echo esc_html( $display_label . $details_label ); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ( ! empty( $people ) ) : ?>
<ul class="jetpack-podcast-episode__people">
<?php
foreach ( $people as $person ) :
if ( ! is_array( $person ) || empty( $person['name'] ) ) {
continue;
}
$person_name = (string) $person['name'];
$person_role = isset( $person['role'] ) ? (string) $person['role'] : '';
$person_href = isset( $person['href'] ) ? esc_url_raw( $person['href'] ) : '';
$person_img = isset( $person['img'] ) ? esc_url_raw( $person['img'] ) : '';
?>
<li class="jetpack-podcast-episode__person" itemprop="contributor" itemscope itemtype="https://schema.org/Person">
<?php if ( $person_img ) : ?>
<img src="<?php echo esc_url( $person_img ); ?>" alt="" loading="lazy" />
<?php endif; ?>
<?php if ( $person_href ) : ?>
<a href="<?php echo esc_url( $person_href ); ?>" itemprop="url">
<span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
</a>
<?php else : ?>
<span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
<?php endif; ?>
<?php if ( $person_role ) : ?>
<span class="jetpack-podcast-episode__person-role"><?php echo esc_html( $person_role ); ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ( '' !== $show_title ) : ?>
<div class="jetpack-podcast-episode__series" itemprop="partOfSeries" itemscope itemtype="https://schema.org/PodcastSeries">
<meta itemprop="name" content="<?php echo esc_attr( $show_title ); ?>" />
<?php if ( $show_image_url ) : ?>
<meta itemprop="image" content="<?php echo esc_url( $show_image_url ); ?>" />
<?php endif; ?>
<?php if ( $show_email ) : ?>
<span itemprop="publisher" itemscope itemtype="https://schema.org/Organization">
<meta itemprop="email" content="<?php echo esc_attr( $show_email ); ?>" />
</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ( $transcript_url || $location_name || $license ) : ?>
<ul class="jetpack-podcast-episode__links">
<?php if ( $transcript_url ) : ?>
<li itemprop="transcript" itemscope itemtype="https://schema.org/MediaObject">
<meta itemprop="contentUrl" content="<?php echo esc_url( $transcript_url ); ?>" />
<?php if ( '' !== $transcript_type ) : ?>
<meta itemprop="encodingFormat" content="<?php echo esc_attr( $transcript_type ); ?>" />
<?php endif; ?>
<a href="<?php echo esc_url( $transcript_url ); ?>" class="jetpack-podcast-episode__transcript-link">
<?php esc_html_e( 'Read transcript', 'jetpack-podcast' ); ?>
</a>
</li>
<?php endif; ?>
<?php if ( $location_name ) : ?>
<li class="jetpack-podcast-episode__location" itemprop="contentLocation"><?php echo esc_html( $location_name ); ?></li>
<?php endif; ?>
<?php if ( $license ) : ?>
<li class="jetpack-podcast-episode__license">
<?php
/* translators: %s: license identifier (e.g. "CC-BY-4.0"). */
$license_label = sprintf( _x( 'License: %s', 'episode metadata license label', 'jetpack-podcast' ), $license );
?>
<?php if ( $license_url ) : ?>
<a href="<?php echo esc_url( $license_url ); ?>" itemprop="license"><?php echo esc_html( $license_label ); ?></a>
<?php else : ?>
<?php echo esc_html( $license_label ); ?>
<?php endif; ?>
</li>
<?php endif; ?>
</ul>
<?php endif; ?>
</div>
</article>
</div>
<?php
return ob_get_clean();
}
/**
* Render the block for email via the WooCommerce Email Editor.
*
* Email clients can't run the interactive player, so render a static
* episode card — cover art, title, byline, duration — linking back to
* the episode post, where the full player lives.
*
* @param string $block_content The original block HTML content.
* @param array $parsed_block The parsed block data including attributes.
* @param object $rendering_context Email rendering context.
* @return string
*/
public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
return '';
}
$attrs = $parsed_block['attrs'];
if ( empty( $attrs['mediaUrl'] ) || ! wp_http_validate_url( $attrs['mediaUrl'] ) ) {
return '';
}
$post = get_post();
if ( ! $post ) {
return '';
}
$post_url = get_permalink( $post );
if ( empty( $post_url ) ) {
return '';
}
$title = get_the_title( $post );
$author_name = get_the_author_meta( 'display_name', (int) $post->post_author );
$publish_date = get_the_date( '', $post );
$duration = isset( $attrs['duration'] ) ? trim( (string) $attrs['duration'] ) : '';
$season_number = isset( $attrs['seasonNumber'] ) ? (int) $attrs['seasonNumber'] : 0;
$episode_number = isset( $attrs['episodeNumber'] ) ? (int) $attrs['episodeNumber'] : 0;
$image_url = self::resolve_cover_art_url( $attrs, $post, 'thumbnail' );
if ( '' !== $image_url && ! wp_http_validate_url( $image_url ) ) {
$image_url = '';
}
$cta_label = isset( $attrs['mediaType'] ) && 'video' === $attrs['mediaType']
? __( 'Watch the episode', 'jetpack-podcast' )
: __( 'Listen to the episode', 'jetpack-podcast' );
$meta_parts = array();
if ( $season_number ) {
/* translators: %d: season number. */
$meta_parts[] = sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number );
}
if ( $episode_number ) {
/* translators: %d: episode number. */
$meta_parts[] = sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number );
}
$byline_parts = array_filter( array( $author_name, $publish_date, $duration ) );
$body = '';
if ( $meta_parts ) {
$body .= sprintf(
'<p style="margin: 0 0 4px; font-size: 12px; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.5px; color: #757575;">%s</p>',
esc_html( implode( ' · ', $meta_parts ) )
);
}
if ( $title ) {
$body .= sprintf(
'<h3 style="margin: 0 0 4px; font-size: 18px; line-height: 1.3;"><a href="%s" style="color: inherit; text-decoration: none;">%s</a></h3>',
esc_url( $post_url ),
esc_html( $title )
);
}
if ( $byline_parts ) {
$body .= sprintf(
'<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.4; color: #757575;">%s</p>',
esc_html( implode( ' · ', $byline_parts ) )
);
}
$body .= sprintf(
'<p style="margin: 0; font-size: 14px;"><a href="%s" style="font-weight: 600;">&#9654;&nbsp; %s</a></p>',
esc_url( $post_url ),
esc_html( $cta_label )
);
$cells = '';
if ( '' !== $image_url ) {
$image_link = sprintf(
'<a href="%s"><img src="%s" alt="" width="96" height="96" style="display: block; width: 96px; height: 96px; border-radius: 4px;" /></a>',
esc_url( $post_url ),
esc_url( $image_url )
);
// Padding lives on an inner wrapper, not the cell: the engine's
// mobile media query zeroes `.layout-flex-item` horizontal padding
// when it stacks the card, which would otherwise flush the content
// against the border.
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
'<div style="padding: 16px 0 0 16px;">' . $image_link . '</div>',
array(
'class' => 'layout-flex-item',
'width' => '96',
'valign' => 'top',
'style' => 'width: 96px;',
)
);
}
// Wrap the body in a nested table rather than dropping the loose <p>/<h3>
// straight into the layout cell. Core blocks (e.g. Media_Text) never put
// bare block-level elements in a cell — they table-wrap content so it
// survives email pipelines intact. The padding rides on the nested cell,
// which the engine's mobile `.layout-flex-item td` rule leaves untouched,
// so it also stays put when the card stacks.
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$body_table = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
$body,
array( 'style' => 'width: 100%; border-collapse: collapse;' ),
array( 'style' => 'padding: 16px;' )
);
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
$body_table,
array(
'class' => 'layout-flex-item',
'valign' => 'top',
)
);
// Cap the card to the email layout width when the context exposes it
// (method_exists guards against older email editor versions).
$target_width = 600;
if ( method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$layout_width = (int) \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
if ( $layout_width > 0 ) {
$target_width = $layout_width;
}
}
// Preserve the vertical gap from email_attrs — the engine's spacing
// preprocessor sets `margin-top`; horizontal root padding is applied
// to the callback output by the engine itself.
$email_attrs = $parsed_block['email_attrs'] ?? array();
$table_margin_style = (string) \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin', 'margin-top' ) ) ), '' );
// `layout-flex-wrapper` opts the card into the engine's own mobile
// stacking: its template-canvas.css media query collapses
// `.layout-flex-wrapper`/`.layout-flex-item` to full-width blocks under
// 660px, so no custom media query (or template-style filter) is needed.
// border-collapse must stay `separate` for the rounded card border to render.
$table_style = sprintf(
'%s width: 100%%; max-width: %dpx; border-collapse: separate; border: 1px solid #ddd; border-radius: 6px;',
$table_margin_style ? $table_margin_style : 'margin: 16px 0;',
$target_width
);
// Append user-set block supports (padding, border, colors) so editor
// styling overrides the card defaults.
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$user_styles = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::get_block_styles( $attrs, $rendering_context, array( 'padding', 'border', 'background-color', 'color' ) );
if ( ! empty( $user_styles['css'] ) ) {
$table_style .= ' ' . $user_styles['css'];
}
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
$cells,
array(
'class' => 'jetpack-podcast-episode-email-card layout-flex-wrapper',
'style' => $table_style,
),
array(),
array(),
false
);
}
}
@@ -0,0 +1,238 @@
<?php
/**
* Registers the Jetpack Podcast wp-admin page and loads the wp-build dashboard.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Status\Host;
use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
/**
* Adds the "Jetpack > Podcast" wp-admin screen.
*/
class Admin_Page {
const ADMIN_PAGE_SLUG = 'jetpack-podcast';
/**
* Query var the checkout return URL carries so the gate busts its cached
* purchases lookup the instant a buyer lands back on the dashboard. Kept in
* sync with the `podcast_purchased` literal in `withPurchaseReturnMarker()`
* (`src/dashboard/upgrade.ts`).
*/
const PURCHASE_RETURN_QUERY_VAR = 'podcast_purchased';
/**
* Where the Podcast item sits in the Jetpack submenu on self-hosted.
*
* Placed after Subscribers (15) and Newsletter (10) to mirror the order
* `wpcom-admin-menu.php` uses on Simple/Atomic.
*/
const MENU_POSITION = 16;
/**
* Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
* callback only fires when `$screen->id` matches this value, so we alias
* the screen id via `current_screen` without changing the user-facing URL.
*/
const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
/**
* Whether `init()` has already wired its hooks.
*
* @var bool
*/
private static $initialized = false;
/**
* Wire admin hooks. Idempotent.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
// On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
// priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
// has no such file, so we register our own. Priority 999 queues the item
// before Admin_Menu's priority-1000 callback.
if ( ! ( new Host() )->is_wpcom_platform() ) {
add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
}
}
/**
* Register the Podcast submenu under the Jetpack menu.
*/
public static function add_wp_admin_submenu() {
// Prefer the wp-build render function once it's defined (by
// maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
$wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
$callback = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
if ( ( new Host() )->is_wpcom_platform() ) {
$page_suffix = add_submenu_page(
'jetpack',
/** "Podcast" is a product name, do not translate. */
'Podcast',
'Podcast',
'manage_options',
self::ADMIN_PAGE_SLUG,
$callback
);
} else {
$page_suffix = Admin_Menu::add_menu(
/** "Podcast" is a product name, do not translate. */
'Podcast',
'Podcast',
'manage_options',
self::ADMIN_PAGE_SLUG,
$callback,
self::MENU_POSITION
);
}
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
}
}
/**
* Wire admin-init actions once we know the Podcast page is loading.
*/
public static function admin_init() {
// MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
}
/**
* Hooked at admin_menu priority 1 so polyfills register before
* `wp_default_scripts` fires and the wp-build render function is defined
* before `add_wp_admin_submenu()` runs (priority 999 on self-hosted, 999999
* on Simple/Atomic).
*/
public static function maybe_load_wp_build() {
if ( ! self::is_podcast_admin_request() ) {
return;
}
self::load_wp_build();
add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
}
/**
* Add the podcast gate boolean to `window.JetpackScriptData`.
*
* Hooked from `maybe_load_wp_build()` so it only runs when the request is
* for the podcast admin page.
*
* @param array $data Script data being injected.
* @return array
*/
public static function inject_podcast_script_data( $data ) {
if ( ! is_array( $data ) ) {
$data = array();
}
$is_wpcom = ( new Host() )->is_wpcom_platform();
if ( ! $is_wpcom && empty( $data['site']['wpcom']['blog_id'] ) ) {
$blog_id = (int) Connection_Manager::get_site_id( true );
if ( $blog_id > 0 ) {
$data['site']['wpcom']['blog_id'] = $blog_id;
}
}
// A buyer returning from checkout carries the purchase marker; bust the
// cached purchases lookup so the gate re-reads `/upgrades` and unlocks
// the paid surfaces now instead of after the transient expires.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET[ self::PURCHASE_RETURN_QUERY_VAR ] ) ) {
Podcast_Gate::flush_purchases_cache();
}
// Self-hosted upsells the Growth plan; WordPress.com keeps Premium.
// `product_slug` is fed straight to the checkout URL; `plan_name` is a
// product name shown in the locked-preview copy (not translated).
$data['podcast'] = array(
'has_product_access' => Podcast_Gate::has_product_access(),
'is_connected' => $is_wpcom || ( new Connection_Manager( 'jetpack' ) )->is_connected(),
'show_url_hosts' => Settings::SHOW_URL_HOSTS,
'show_url_max_length' => Settings::SHOW_URL_MAX_LENGTH,
// Settings only: categories rejects per_page=-1 server-side, stats is a live relay.
'preload' => rest_preload_api_request( array(), '/wpcom/v2/podcast/settings' ),
'upgrade' => array(
'product_slug' => $is_wpcom ? 'premium' : 'jetpack_growth_yearly',
'plan_name' => $is_wpcom ? 'Premium' : 'Growth',
),
);
return $data;
}
/**
* The build artifact may be absent on a fresh checkout before
* `pnpm build` has run; in that case `add_wp_admin_submenu()` falls back
* to `render()` so the page still loads (just without the React app).
*/
private static function load_wp_build() {
$build_index = dirname( __DIR__ ) . '/build/build.php';
if ( ! file_exists( $build_index ) ) {
return;
}
require_once $build_index;
WP_Build_Polyfills::register(
'jetpack-podcast',
array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
);
}
/**
* Alias the current screen id to wp-build's expected slug.
*
* @param \WP_Screen|null $screen The current screen object (passed by WP).
*/
public static function alias_screen_id_for_wp_build( $screen ) {
if ( ! is_object( $screen ) ) {
return;
}
$screen->id = self::WP_BUILD_SLUG;
}
/**
* Fallback render used when the wp-build artifact is missing.
*/
public static function render() {
?>
<div class="wrap">
<h1>Podcast</h1>
</div>
<?php
}
/**
* Whether the current request targets the Podcast admin page.
*/
private static function is_podcast_admin_request() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
return false;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return self::ADMIN_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
}
}
@@ -0,0 +1,902 @@
<?php
/**
* Create AI Podcast — wp-admin page under Media.
*
* Standalone PHP page (no wp-build chassis, no React). Renders a static
* form server-side; a single vanilla-JS island fetches quota, drives the
* posts picker, submits the generate request, polls the job, and resumes
* across reloads via localStorage.
*
* Bootstrapped from `Podcast::init()` after the Host (Simple/WoA) gate has
* already been checked upstream.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
require_once __DIR__ . '/admin-pages/create-ai-podcast/presets.php';
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Status\Host;
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\length_presets;
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\voice_presets;
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\window_presets;
/**
* Registers the Media > Create AI Podcast submenu and renders the page.
*/
class Create_AI_Podcast_Page {
const PAGE_SLUG = 'create-ai-podcast';
const SCRIPT_HANDLE = 'jetpack-create-ai-podcast';
const STYLE_HANDLE = 'jetpack-create-ai-podcast';
const EPISODES_PER_PAGE = 5;
/**
* Maximum number of posts that can be selected when generating from specific posts.
*/
const MAX_SELECTED_POSTS = 25;
const POST_PUBLISH_PROMO_SCRIPT_HANDLE = 'jetpack-post-publish-podcast-promo';
const POST_PUBLISH_PROMO_DISMISSED_OPTION = 'jetpack_posts_to_podcast_post_publish_promo_dismissed';
const POST_PUBLISH_PROMO_MIN_POSTS = 5;
const POST_PUBLISH_PROMO_MIN_VISITORS = 50;
/**
* Whether `init()` has wired its hooks.
*
* @var bool
*/
private static $initialized = false;
/**
* Wire admin hooks. Idempotent.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) );
add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_post_publish_promo_assets' ) );
}
/**
* Register the Media > Create AI Podcast submenu.
*/
public static function register_menu() {
$page_suffix = add_submenu_page(
'upload.php',
__( 'Create AI Podcast', 'jetpack-podcast' ),
__( 'Create AI Podcast', 'jetpack-podcast' ),
'upload_files',
self::PAGE_SLUG,
array( __CLASS__, 'render' )
);
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_load' ) );
}
}
/**
* Wire enqueue once we know the Create AI Podcast page is loading.
*/
public static function on_load() {
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
add_action( 'admin_head-media_page_create-ai-podcast', array( __CLASS__, 'render_resource_hints' ) );
}
/**
* On wpcom Simple sites every wp.apiFetch call is routed through the
* wpcom-proxy iframe at public-api.wordpress.com. The iframe load adds a
* full DNS + TLS round-trip before our prefetched quota/episodes requests
* can leave the page. Preconnect shaves that off.
*/
public static function render_resource_hints() {
?>
<link rel="preconnect" href="https://public-api.wordpress.com" crossorigin>
<link rel="dns-prefetch" href="//public-api.wordpress.com">
<?php
}
/**
* Enqueue the static JS island, the page stylesheet, and the localized data bundle.
*/
public static function enqueue_assets() {
$base_url = plugins_url( 'admin-pages/create-ai-podcast/', __FILE__ );
$base_path = __DIR__ . '/admin-pages/create-ai-podcast/';
wp_enqueue_style(
self::STYLE_HANDLE,
$base_url . 'style.css',
array(),
(string) ( file_exists( $base_path . 'style.css' ) ? filemtime( $base_path . 'style.css' ) : '0.1.0' )
);
wp_enqueue_script(
self::SCRIPT_HANDLE,
$base_url . 'index.js',
array( 'wp-api-fetch', 'wp-i18n' ),
(string) ( file_exists( $base_path . 'index.js' ) ? filemtime( $base_path . 'index.js' ) : '0.1.0' ),
true
);
wp_localize_script(
self::SCRIPT_HANDLE,
'jetpackCreateAiPodcast',
self::build_localized_data()
);
}
/**
* Enqueue the post-publish modal in the post block editor for eligible sites.
*/
public static function enqueue_post_publish_promo_assets() {
if (
! self::is_post_block_editor()
|| self::is_current_post_published_for_post_publish_promo()
|| ! self::is_post_publish_promo_site_eligible()
) {
return;
}
Assets::register_script(
self::POST_PUBLISH_PROMO_SCRIPT_HANDLE,
'../dist/blocks/post-publish-podcast-promo/editor.js',
__FILE__,
array(
'enqueue' => true,
'in_footer' => true,
'textdomain' => 'jetpack-podcast',
)
);
wp_add_inline_script(
self::POST_PUBLISH_PROMO_SCRIPT_HANDLE,
'window.jetpackPostPublishPodcastPromo = ' . wp_json_encode(
array(
'createUrl' => admin_url( 'upload.php?page=' . self::PAGE_SLUG ),
'dismissPath' => Posts_To_Podcast_Endpoint::get_post_publish_promo_dismiss_rest_path(),
),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) . ';',
'before'
);
}
/**
* Whether the current editor post has already been published.
*/
private static function is_current_post_published_for_post_publish_promo(): bool {
$post = get_post();
return $post instanceof \WP_Post
&& 'post' === $post->post_type
&& 'publish' === $post->post_status;
}
/**
* Whether the current screen is the post block editor.
*/
private static function is_post_block_editor(): bool {
if ( ! function_exists( 'get_current_screen' ) ) {
return false;
}
$screen = get_current_screen();
return ! empty( $screen )
&& 'post' === $screen->base
&& 'post' === $screen->post_type
&& $screen->is_block_editor();
}
/**
* Whether the site has enough published posts to generate a better episode.
*/
private static function has_enough_recent_posts_for_post_publish_promo(): bool {
/**
* Filters the minimum posts published in the last month needed for the Posts to Podcast post-publish promo.
*
* @since 1.0.0
*
* @param int $minimum Minimum number of published posts.
*/
$minimum = (int) apply_filters(
'jetpack_posts_to_podcast_post_publish_promo_min_published_posts',
self::POST_PUBLISH_PROMO_MIN_POSTS
);
$minimum = max( 1, $minimum );
$published_posts = get_posts(
array(
'fields' => 'ids',
'no_found_rows' => true,
'post_status' => 'publish',
'post_type' => 'post',
'posts_per_page' => $minimum,
'suppress_filters' => false,
'date_query' => array(
array(
'after' => '1 month ago',
'inclusive' => true,
),
),
)
);
$total = count( $published_posts );
$post = get_post();
if ( $post && 'post' === $post->post_type && 'publish' !== $post->post_status ) {
++$total;
}
return $total >= $minimum;
}
/**
* Whether the site has visitors who could benefit from a podcast episode.
*/
private static function has_visitors_for_post_publish_promo(): bool {
$visitors = self::get_post_publish_promo_visitor_count();
/**
* Filters the minimum visitors in the last week needed for the Posts to Podcast post-publish promo.
*
* @since 1.0.0
*
* @param int $minimum Minimum number of visitors.
*/
$minimum = (int) apply_filters(
'jetpack_posts_to_podcast_post_publish_promo_min_visitors',
self::POST_PUBLISH_PROMO_MIN_VISITORS
);
return $visitors >= max( 1, $minimum );
}
/**
* Fetch the last week's visitor count from Jetpack Stats when available.
*/
private static function get_post_publish_promo_visitor_count(): int {
$host = new Host();
if ( $host->is_wpcom_simple() ) {
return self::get_wpcom_simple_post_publish_promo_visitor_count();
}
if ( class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' ) ) {
$wpcom_stats = new \Automattic\Jetpack\Stats\WPCOM_Stats();
$stats = $wpcom_stats->get_visits(
array(
'unit' => 'day',
'quantity' => 7,
'stat_fields' => 'visitors',
)
);
if ( ! is_wp_error( $stats ) && is_array( $stats ) ) {
return self::sum_visits_field( $stats, 'visitors' );
}
}
return 0;
}
/**
* Fetch the last week's visitor count directly on WordPress.com Simple.
*/
private static function get_wpcom_simple_post_publish_promo_visitor_count(): int {
if ( ! function_exists( 'stats_get_visitors' ) ) {
return 0;
}
$visitors = stats_get_visitors( get_current_blog_id(), gmdate( 'Y-m-d' ), 7, 1 );
return is_array( $visitors ) ? (int) array_sum( $visitors ) : 0;
}
/**
* Sum a metric from the Stats visits response.
*
* @param array $stats Stats visits response.
* @param string $field Field to sum.
* @return int
*/
private static function sum_visits_field( array $stats, string $field ): int {
if ( ! isset( $stats['data'] ) || ! is_array( $stats['data'] ) ) {
return 0;
}
$fields = isset( $stats['fields'] ) && is_array( $stats['fields'] ) ? $stats['fields'] : array();
$index = array_search( $field, $fields, true );
if ( false === $index ) {
return 0;
}
$total = 0;
foreach ( $stats['data'] as $row ) {
if ( is_array( $row ) && isset( $row[ $index ] ) ) {
$total += (int) $row[ $index ];
}
}
return $total;
}
/**
* Whether the site is relevant for the post-publish promo.
*/
public static function is_post_publish_promo_site_eligible(): bool {
$host = new Host();
if ( $host->is_p2_site() ) {
return false;
}
if ( get_user_option( self::POST_PUBLISH_PROMO_DISMISSED_OPTION, get_current_user_id() ) ) {
return false;
}
return self::has_enough_recent_posts_for_post_publish_promo() && self::has_visitors_for_post_publish_promo();
}
/**
* Build the data bundle passed to the JS island via wp_localize_script.
*
* @return array<string, mixed>
*/
private static function build_localized_data(): array {
$max_posts = self::MAX_SELECTED_POSTS;
return array(
'maxPosts' => $max_posts,
'endpoints' => array(
'enqueue' => '/wpcom/v2/posts-to-podcast',
'job' => '/wpcom/v2/posts-to-podcast/jobs/',
'quota' => '/wpcom/v2/posts-to-podcast',
'posts' => '/wp/v2/posts',
'episodes' => '/wpcom/v2/posts-to-podcast/episodes',
),
'blogId' => self::resolve_blog_id(),
'bootstrap' => self::bootstrap_data(),
'presets' => array(
'window' => window_presets(),
'length' => length_presets(),
'voice' => voice_presets(),
),
'poll' => array(
'fastMs' => 3000,
'slowMs' => 10000,
'switchMs' => 30000,
'timeoutMs' => 5 * 60 * 1000,
),
'i18n' => array(
'submitting' => __( 'Submitting…', 'jetpack-podcast' ),
'polling' => __( 'Generating your episode…', 'jetpack-podcast' ),
'pollingSubtext' => __( "This usually takes about 3 minutes. You can leave this page and come back — we'll keep working in the background.", 'jetpack-podcast' ),
'succeeded' => __( 'Episode draft ready.', 'jetpack-podcast' ),
'editDraft' => __( 'Edit draft', 'jetpack-podcast' ),
'failed' => __( 'Generation failed.', 'jetpack-podcast' ),
'timedOut' => __( 'Generation is taking longer than expected. Check your drafts.', 'jetpack-podcast' ),
'tryAgain' => __( 'Try again', 'jetpack-podcast' ),
'dismiss' => __( 'Dismiss', 'jetpack-podcast' ),
'notAvailable' => __( 'Create AI Podcast isn\'t available on your current plan.', 'jetpack-podcast' ),
// translators: 1: number of credits used, 2: total credits available.
'creditsUsed' => __( '%1$d of %2$d credits used.', 'jetpack-podcast' ),
'creditsLabel' => __( 'Credits', 'jetpack-podcast' ),
// translators: 1: number of credits used, 2: total credits available.
'creditsCount' => __( '%1$d / %2$d', 'jetpack-podcast' ),
'creditsUnlimited' => __( 'Unlimited generations available.', 'jetpack-podcast' ),
// translators: %d: credits remaining.
'creditsRemaining' => __( '%d remaining', 'jetpack-podcast' ),
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
'creditsResetSummary' => __( 'Resets %s', 'jetpack-podcast' ),
'creditsResetMonthly' => __( 'Resets monthly', 'jetpack-podcast' ),
'relativeToday' => __( 'today', 'jetpack-podcast' ),
'relativeTomorrow' => __( 'tomorrow', 'jetpack-podcast' ),
// translators: %d: number of days until reset.
'relativeDays' => __( 'in %d days', 'jetpack-podcast' ),
// translators: %s: formatted date, e.g. "May 20, 2026".
'relativeOn' => __( 'on %s', 'jetpack-podcast' ),
'trialBannerTitle' => __( 'Try before you buy', 'jetpack-podcast' ),
'trialBannerMessage' => __( 'Generate a podcast from your posts and see how it sounds on your site. Free trial is limited to one podcast episode.', 'jetpack-podcast' ),
'runningLowTitle' => __( 'Running low', 'jetpack-podcast' ),
'runningLowMessage' => __( 'Upgrade your plan to keep generating without waiting for the monthly refresh.', 'jetpack-podcast' ),
'outOfCreditsTitle' => __( 'Out of credits', 'jetpack-podcast' ),
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
'outOfCreditsWait' => __( 'Your credits will refresh %s.', 'jetpack-podcast' ),
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
'outOfCreditsUpgrade' => __( 'Upgrade your plan for more credits, or wait until they refresh %s.', 'jetpack-podcast' ),
'outOfTrialCredits' => __( 'You have used your one-time trial credit. Upgrade your plan for more credits.', 'jetpack-podcast' ),
'noPostsFound' => __( 'No posts match.', 'jetpack-podcast' ),
'loadingPosts' => __( 'Loading posts…', 'jetpack-podcast' ),
'pickPosts' => __( 'Select at least one post to continue.', 'jetpack-podcast' ),
// translators: %d: maximum number of posts that can be selected.
'maxPostsReached' => sprintf( __( 'You can select up to %d posts.', 'jetpack-podcast' ), $max_posts ),
'upgradeCta' => __( 'Upgrade plan', 'jetpack-podcast' ),
'episodesTitle' => __( 'Generated podcasts', 'jetpack-podcast' ),
'episodesEmpty' => __( 'No generated podcasts yet.', 'jetpack-podcast' ),
'episodesLoading' => __( 'Loading podcasts…', 'jetpack-podcast' ),
'editPost' => __( 'Edit post', 'jetpack-podcast' ),
'statusDraft' => __( 'Draft', 'jetpack-podcast' ),
'statusPublished' => __( 'Published', 'jetpack-podcast' ),
// translators: 1: range start, 2: range end, 3: total count. Example: "Showing 15 of 12"
'paginationSummary' => __( 'Showing %1$d%2$d of %3$d', 'jetpack-podcast' ),
'paginationPrev' => __( 'Previous', 'jetpack-podcast' ),
'paginationNext' => __( 'Next', 'jetpack-podcast' ),
// translators: %d: page number. Example: "Go to page 3"
'paginationGoTo' => __( 'Go to page %d', 'jetpack-podcast' ),
'paginationLabel' => __( 'Episodes pagination', 'jetpack-podcast' ),
'unexpectedError' => __( 'An unexpected error occurred.', 'jetpack-podcast' ),
'outOfCreditsError' => __( 'Out of credits.', 'jetpack-podcast' ),
),
);
}
/**
* Resolve the wpcom blog id for this site. On Atomic Jetpack stores it
* under the `id` option; on Simple sites it's the current blog id.
*
* @return int
*/
private static function resolve_blog_id(): int {
if ( class_exists( '\\Jetpack_Options' ) ) {
$id = (int) \Jetpack_Options::get_option( 'id' );
if ( $id > 0 ) {
return $id;
}
}
return (int) get_current_blog_id();
}
/**
* Pre-warm the two initial reads (quota + episodes) server-side via
* rest_do_request so the client can render with data immediately instead
* of waiting on the wpcom-proxy iframe for the first paint. Failures fall
* through silently — the JS island falls back to fetch when an entry is
* null or absent.
*
* @return array{quota: array|null, episodes: array|null}
*/
private static function bootstrap_data(): array {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return self::bootstrap_data_wpcom();
}
return self::bootstrap_data_via_proxy();
}
/**
* Atomic / self-hosted path: hit the local Jetpack-side proxy via
* rest_do_request, which forwards to wpcom over HTTPS using the current
* user's Jetpack token.
*
* @return array
*/
private static function bootstrap_data_via_proxy(): array {
$bootstrap = array(
'quota' => null,
'episodes' => self::empty_episodes_envelope(),
);
$quota_request = new \WP_REST_Request( 'GET', '/wpcom/v2/posts-to-podcast' );
$quota_response = rest_do_request( $quota_request );
if ( $quota_response instanceof \WP_REST_Response ) {
if ( ! $quota_response->is_error() ) {
$bootstrap['quota'] = $quota_response->get_data();
} else {
$status = (int) $quota_response->get_status();
if ( 403 === $status || 404 === $status ) {
$bootstrap['quota'] = array( 'notAvailable' => true );
} else {
$bootstrap['quota'] = array(
'quota' => 0,
'used' => 0,
);
}
}
}
$episodes_request = new \WP_REST_Request( 'GET', '/wpcom/v2/posts-to-podcast/episodes' );
$episodes_request->set_param( 'page', 1 );
$episodes_request->set_param( 'per_page', self::EPISODES_PER_PAGE );
$episodes_response = rest_do_request( $episodes_request );
if ( $episodes_response instanceof \WP_REST_Response && ! $episodes_response->is_error() ) {
$bootstrap['episodes'] = self::normalize_episodes_payload( $episodes_response->get_data() );
}
return $bootstrap;
}
/**
* Replicate the wpcom-side endpoint's active-job payload shape so the
* client can resume polling the "Generating…" notice across reloads on
* Simple sites, the same way it does on Atomic through the proxy.
*
* @param int $blog_id Current blog id.
*
* @return array|\stdClass
*/
private static function build_active_job_payload_wpcom( int $blog_id ) {
if ( ! function_exists( 'posts_to_podcast_get_active_job_record' ) ) {
return new \stdClass();
}
$record = posts_to_podcast_get_active_job_record( $blog_id );
if ( null === $record ) {
return new \stdClass();
}
$status_map = array(
'queued' => 'pending',
'succeeded' => 'complete',
'failed' => 'failed',
);
$raw = function_exists( 'get_job_status' ) ? get_job_status( $record['id'] ) : 'queued';
$status = $status_map[ $raw ] ?? 'pending';
$payload = array(
'jobId' => (int) $record['id'],
'status' => $status,
'createdAt' => gmdate( 'c', (int) $record['queued_at'] ),
);
if ( 'complete' === $status && function_exists( 'posts_to_podcast_get_job_result' ) ) {
$post_id = posts_to_podcast_get_job_result( $record['id'] );
if ( null !== $post_id ) {
$payload['postId'] = $post_id;
$payload['editUrl'] = (string) get_edit_post_link( $post_id, 'raw' );
}
}
return $payload;
}
/**
* Replicate the wpcom-side endpoint's upgrade URL builder so the Out of
* credits banner can surface an Upgrade plan CTA even on Simple sites
* (where we don't go through the REST proxy). Returns the Calypso
* checkout URL for the next tier up, or empty when the site is already
* on the top podcast tier.
*
* @param int $blog_id Current blog id.
*
* @return string
*/
private static function build_upgrade_url_wpcom( int $blog_id ): string {
if ( ! class_exists( '\\WPCOM_Features' ) || ! function_exists( 'wpcom_site_has_feature' ) ) {
return '';
}
if ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_3, $blog_id ) ) {
return '';
}
if ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_2, $blog_id ) ) {
$plan = 'business';
} elseif ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_1, $blog_id ) ) {
$plan = 'premium';
} else {
$plan = 'personal';
}
$site_slug = class_exists( '\\WPCOM_Masterbar' )
? \WPCOM_Masterbar::get_calypso_site_slug( $blog_id )
: '';
if ( '' === $site_slug ) {
return '';
}
return sprintf( 'https://wordpress.com/checkout/%s/%s', $site_slug, $plan );
}
/**
* Default empty episodes envelope used while the page is still loading
* data or when an upstream request errors.
*
* @return array
*/
private static function empty_episodes_envelope(): array {
return array(
'items' => array(),
'total' => 0,
'page' => 1,
'perPage' => self::EPISODES_PER_PAGE,
'totalPages' => 0,
);
}
/**
* Accept either the new envelope shape (preferred) or the legacy bare
* array (older sandboxes that haven't shipped the pagination upgrade yet)
* and return the envelope.
*
* @param mixed $payload Upstream response body.
*
* @return array
*/
private static function normalize_episodes_payload( $payload ): array {
if ( is_array( $payload ) && isset( $payload['items'] ) && is_array( $payload['items'] ) ) {
return array(
'items' => array_values( $payload['items'] ),
'total' => isset( $payload['total'] ) ? (int) $payload['total'] : count( $payload['items'] ),
'page' => isset( $payload['page'] ) ? max( 1, (int) $payload['page'] ) : 1,
'perPage' => isset( $payload['perPage'] ) ? max( 1, (int) $payload['perPage'] ) : self::EPISODES_PER_PAGE,
'totalPages' => isset( $payload['totalPages'] ) ? max( 0, (int) $payload['totalPages'] ) : 0,
);
}
if ( is_array( $payload ) ) {
$items = array_values( $payload );
return array(
'items' => $items,
'total' => count( $items ),
'page' => 1,
'perPage' => self::EPISODES_PER_PAGE,
'totalPages' => count( $items ) > 0 ? 1 : 0,
);
}
return self::empty_episodes_envelope();
}
/**
* Simple (wpcom) path: rest_do_request can't reach the posts-to-podcast
* endpoint here — the wpcom REST plugin loader gates the endpoint files
* behind REST_API_PLUGINS, which isn't set in admin context. Call the
* underlying wpcom helpers directly. Permissions are still enforced via
* the admin caps required to render this page.
*
* @return array
*/
private static function bootstrap_data_wpcom(): array {
$bootstrap = array(
'quota' => null,
'episodes' => self::empty_episodes_envelope(),
);
if ( ! function_exists( 'require_lib' ) ) {
return $bootstrap;
}
require_lib( 'posts-to-podcast' );
$blog_id = (int) get_current_blog_id();
if ( function_exists( '\\Automattic\\Posts_To_Podcast\\get_usage' ) || function_exists( 'posts_to_podcast_get_usage' ) ) {
$usage = function_exists( 'posts_to_podcast_get_usage' )
? posts_to_podcast_get_usage( $blog_id )
: array();
$bootstrap['quota'] = array(
'quota' => $usage,
'activeJob' => self::build_active_job_payload_wpcom( $blog_id ),
'upgradeUrl' => self::build_upgrade_url_wpcom( $blog_id ),
);
}
$per_page = self::EPISODES_PER_PAGE;
$query = new \WP_Query(
array(
'post_type' => 'post',
'post_status' => array( 'draft', 'publish' ),
'posts_per_page' => $per_page,
'paged' => 1,
'orderby' => 'date',
'order' => 'DESC',
'update_post_term_cache' => false,
'meta_query' => array(
array(
'key' => 'posts_to_podcast_metadata',
'compare' => 'EXISTS',
),
),
)
);
$items = array();
foreach ( $query->posts as $post ) {
$raw_meta = get_post_meta( $post->ID, 'posts_to_podcast_metadata', true );
$meta = is_string( $raw_meta ) ? json_decode( $raw_meta, true ) : null;
$audio = ( is_array( $meta ) && isset( $meta['audio'] ) && is_array( $meta['audio'] ) ) ? $meta['audio'] : array();
$title = wp_strip_all_tags(
html_entity_decode( (string) get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' )
);
if ( '' === trim( $title ) ) {
// translators: Fallback shown in the Generated podcasts list when a draft has an empty title.
$title = __( '(no title)', 'jetpack-podcast' );
}
$items[] = array(
'id' => $post->ID,
'title' => $title,
'status' => $post->post_status,
'date' => mysql2date( 'c', $post->post_date_gmt, false ),
'editUrl' => get_edit_post_link( $post->ID, 'raw' ),
'mediaUrl' => isset( $audio['url'] ) ? esc_url_raw( (string) $audio['url'] ) : '',
'mediaType' => 'audio',
'mediaMime' => isset( $audio['mimeType'] ) ? (string) $audio['mimeType'] : '',
'duration' => isset( $audio['durationSeconds'] ) ? (int) round( (float) $audio['durationSeconds'] ) : 0,
);
}
$total = (int) $query->found_posts;
$bootstrap['episodes'] = array(
'items' => $items,
'total' => $total,
'page' => 1,
'perPage' => $per_page,
'totalPages' => (int) ceil( $total / $per_page ),
);
return $bootstrap;
}
/**
* Render the page chrome and the static form HTML.
*/
public static function render() {
$window = window_presets();
$length = length_presets();
$voice = voice_presets();
?>
<div class="wrap jetpack-create-ai-podcast">
<h1 class="jetpack-create-ai-podcast__page-title">
<?php echo esc_html__( 'Create AI Podcast', 'jetpack-podcast' ); ?>
</h1>
<div id="jetpack-create-ai-podcast-app">
<section class="jetpack-create-ai-podcast__intro" role="region" aria-labelledby="jetpack-create-ai-podcast-intro-title">
<div class="jetpack-create-ai-podcast__intro-body">
<div class="jetpack-create-ai-podcast__intro-badges">
<p class="jetpack-create-ai-podcast__intro-eyebrow">
<span class="jetpack-create-ai-podcast__intro-wpmark" aria-hidden="true"></span>
<span><?php echo esc_html__( 'WordPress.com exclusive', 'jetpack-podcast' ); ?></span>
</p>
<p class="jetpack-create-ai-podcast__intro-eyebrow jetpack-create-ai-podcast__intro-eyebrow--experimental">
<span><?php echo esc_html__( 'Experimental', 'jetpack-podcast' ); ?></span>
</p>
</div>
<h2 id="jetpack-create-ai-podcast-intro-title" class="jetpack-create-ai-podcast__intro-title">
<?php echo esc_html__( 'Turn your posts into a podcast episode', 'jetpack-podcast' ); ?>
</h2>
<p class="jetpack-create-ai-podcast__intro-text">
<?php echo esc_html__( 'Pick a date range or a few specific posts and well generate a two-host conversation, complete with narration and a ready-to-publish draft. Edit, refine, and hit Publish when youre happy.', 'jetpack-podcast' ); ?>
</p>
</div>
<div class="jetpack-create-ai-podcast__intro-art" aria-hidden="true">
<svg viewBox="0 0 64 64" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="jetpack-create-ai-podcast-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="28" fill="url(#jetpack-create-ai-podcast-grad)"/>
<path fill="#fff" d="M32 14a8 8 0 0 0-8 8v10a8 8 0 0 0 16 0V22a8 8 0 0 0-8-8zm-12 18a1.5 1.5 0 0 1 3 0 9 9 0 0 0 18 0 1.5 1.5 0 0 1 3 0 12 12 0 0 1-10.5 11.9V48h4.5v3h-12v-3H30v-2.1A12 12 0 0 1 20 32z"/>
</svg>
</div>
</section>
<div
class="jetpack-create-ai-podcast__card jetpack-create-ai-podcast__credits"
data-region="credits"
></div>
<form class="jetpack-create-ai-podcast__form" data-region="form">
<section class="jetpack-create-ai-podcast__card">
<h2 class="jetpack-create-ai-podcast__card-title">
<?php echo esc_html__( 'Source', 'jetpack-podcast' ); ?>
</h2>
<div class="jetpack-create-ai-podcast__radio-group" role="radiogroup">
<label class="jetpack-create-ai-podcast__radio">
<input type="radio" name="source" value="window" checked>
<span><?php echo esc_html__( 'From a date range', 'jetpack-podcast' ); ?></span>
</label>
<label class="jetpack-create-ai-podcast__radio">
<input type="radio" name="source" value="posts">
<span><?php echo esc_html__( 'From specific posts', 'jetpack-podcast' ); ?></span>
</label>
</div>
<div class="jetpack-create-ai-podcast__field" data-source="window">
<label for="jetpack-create-ai-podcast-window">
<?php echo esc_html__( 'Date range', 'jetpack-podcast' ); ?>
</label>
<select id="jetpack-create-ai-podcast-window" name="window">
<?php foreach ( $window as $opt ) : ?>
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="jetpack-create-ai-podcast__field" data-source="posts" hidden>
<label for="jetpack-create-ai-podcast-posts-search">
<?php echo esc_html__( 'Search posts', 'jetpack-podcast' ); ?>
</label>
<input
type="search"
id="jetpack-create-ai-podcast-posts-search"
placeholder="<?php echo esc_attr__( 'Type to filter…', 'jetpack-podcast' ); ?>"
>
<p class="jetpack-create-ai-podcast__field-hint">
<?php
echo esc_html(
sprintf(
/* translators: %d: maximum number of posts that can be selected. */
__( 'You can choose up to %d posts.', 'jetpack-podcast' ),
self::MAX_SELECTED_POSTS
)
);
?>
</p>
<div class="jetpack-create-ai-podcast__posts" data-region="posts"></div>
</div>
<div class="jetpack-create-ai-podcast__advanced" aria-labelledby="jetpack-create-ai-podcast-advanced-title">
<div class="jetpack-create-ai-podcast__advanced-header">
<h3 id="jetpack-create-ai-podcast-advanced-title" class="jetpack-create-ai-podcast__advanced-title">
<?php echo esc_html__( 'Customize', 'jetpack-podcast' ); ?>
</h3>
<span class="jetpack-create-ai-podcast__soon-pill">
<?php echo esc_html__( 'Coming soon', 'jetpack-podcast' ); ?>
</span>
</div>
<div class="jetpack-create-ai-podcast__field">
<label for="jetpack-create-ai-podcast-length">
<?php echo esc_html__( 'Length', 'jetpack-podcast' ); ?>
</label>
<select id="jetpack-create-ai-podcast-length" name="length" disabled data-locked-disabled="true">
<?php foreach ( $length as $opt ) : ?>
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="jetpack-create-ai-podcast__field">
<label for="jetpack-create-ai-podcast-voice">
<?php echo esc_html__( 'Voice', 'jetpack-podcast' ); ?>
</label>
<select id="jetpack-create-ai-podcast-voice" name="voice" disabled data-locked-disabled="true">
<?php foreach ( $voice as $opt ) : ?>
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="jetpack-create-ai-podcast__field">
<label for="jetpack-create-ai-podcast-prompt">
<?php echo esc_html__( 'Prompt (optional)', 'jetpack-podcast' ); ?>
</label>
<textarea
id="jetpack-create-ai-podcast-prompt"
name="prompt"
rows="3"
disabled
data-locked-disabled="true"
placeholder="<?php echo esc_attr__( 'Steer the tone, framing, or focus of the episode…', 'jetpack-podcast' ); ?>"
></textarea>
</div>
</div>
<div class="jetpack-create-ai-podcast__actions">
<button type="submit" class="button button-primary button-hero">
<?php echo esc_html__( 'Generate', 'jetpack-podcast' ); ?>
</button>
</div>
</section>
</form>
<div class="jetpack-create-ai-podcast__status" aria-live="polite" data-region="status"></div>
<section
class="jetpack-create-ai-podcast__card jetpack-create-ai-podcast__episodes"
data-region="episodes"
aria-busy="false"
>
<h2 class="jetpack-create-ai-podcast__card-title">
<?php echo esc_html__( 'Generated podcasts', 'jetpack-podcast' ); ?>
</h2>
<div class="jetpack-create-ai-podcast__episodes-list" data-region="episodes-list"></div>
</section>
</div>
</div>
<?php
}
}
@@ -0,0 +1,131 @@
<?php
/**
* Server-side prefill for `post-new.php?podcast_episode=1`.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
/**
* Prefills the new-post screen with the configured podcast category and, on
* Premium, an inserted Podcast Episode block.
*/
class New_Episode_Prefill {
const QUERY_VAR = 'podcast_episode';
/**
* ID of the auto-draft we've already handled this request.
*
* @var int
*/
private static $handled_post_id = 0;
/**
* Register hooks.
*/
public static function init() {
add_action( 'admin_init', array( __CLASS__, 'maybe_register_handlers' ) );
}
/**
* Bind on `admin_init` so `$pagenow` is settled.
*/
public static function maybe_register_handlers() {
global $pagenow;
if ( 'post-new.php' !== $pagenow ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET[ self::QUERY_VAR ] ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( '1' !== sanitize_text_field( wp_unslash( $_GET[ self::QUERY_VAR ] ) ) ) {
return;
}
if ( (int) get_option( 'podcasting_category_id', 0 ) <= 0 ) {
return;
}
add_action( 'wp_insert_post', array( __CLASS__, 'assign_category' ), 10, 3 );
if ( Podcast_Gate::has_product_access() ) {
add_filter( 'default_content', array( __CLASS__, 'prefill_block_content' ), 10, 2 );
}
}
/**
* Assign the configured podcast category to the new auto-draft.
*
* Narrowed to the initial auto-draft so user-driven saves later in the
* session aren't re-overridden; self-unhooks so sibling auto-drafts in the
* same request are left alone.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param bool $update True for updates, false for inserts.
*/
public static function assign_category( $post_id, $post, $update ) {
if ( $update ) {
return;
}
if ( ! ( $post instanceof \WP_Post ) ) {
return;
}
if ( ! self::is_supported_post( $post ) || 'auto-draft' !== $post->post_status ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
$category_id = (int) get_option( 'podcasting_category_id', 0 );
if ( $category_id <= 0 ) {
return;
}
wp_set_post_categories( $post_id, array( $category_id ) );
self::$handled_post_id = (int) $post_id;
remove_action( 'wp_insert_post', array( __CLASS__, 'assign_category' ), 10 );
}
/**
* Inject a Podcast Episode block as the new post's initial content.
*
* No-op if another plugin has already filled `$content`, so this composes
* politely.
*
* @param string $content Default post content.
* @param \WP_Post $post Post object.
* @return string
*/
public static function prefill_block_content( $content, $post ) {
if ( ! self::is_supported_post( $post ) ) {
return $content;
}
if ( self::$handled_post_id > 0 && (int) $post->ID !== self::$handled_post_id ) {
return $content;
}
if ( '' !== trim( (string) $content ) ) {
return $content;
}
remove_filter( 'default_content', array( __CLASS__, 'prefill_block_content' ), 10 );
return "<!-- wp:jetpack/podcast-episode /-->\n";
}
/**
* Whether the post is a core `post` we prefill for.
*
* @param mixed $post Candidate post object.
* @return bool
*/
private static function is_supported_post( $post ) {
return $post instanceof \WP_Post && 'post' === $post->post_type;
}
}
@@ -0,0 +1,177 @@
<?php
/**
* Podcast product-access gate.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Current_Plan;
use Automattic\Jetpack\Status\Host;
use Jetpack_Options;
/**
* Premium podcast feature gate.
*
* Resolves the paid surfaces (episode dashboard, stats, episode block) two
* ways depending on the host:
*
* - WordPress.com (Simple/WoA): the `podcasting` plan feature via
* `Current_Plan::supports`, plus the launch-day grandfather rule. Reads
* request-scoped state, so callers gating a different blog must
* `switch_to_blog` first.
* - Self-hosted Jetpack: the site's purchased plan over the Jetpack
* connection. Per PODS-123, the Growth (and Complete) plans unlock the paid
* surfaces; everything else is feed-only.
*/
class Podcast_Gate {
const FEATURE_SLUG = 'podcasting';
/**
* Launch-day cutoff for the paying-blog grandfather rule. Paid blogs
* registered before this date keep Premium podcast features without
* needing the `podcasting` plan feature. WordPress.com only.
*/
const GRANDFATHER_CUTOFF_DATE = '2026-05-18';
/**
* Transient holding the cached `/upgrades` response. Short-lived (30s, set
* below): mainly dedupes the lookup across a single page load. A buyer
* returning from checkout busts it outright via `flush_purchases_cache()`,
* so the TTL only bounds the unlikely case where that signal is missed.
*/
const PURCHASES_TRANSIENT = 'jetpack_podcast_site_purchases';
/**
* Request-scoped memo of the purchases lookup (including failures, so a
* failed fetch isn't retried mid-request). Null until first resolved.
*
* @var array|null
*/
private static $purchases_cache = null;
/**
* Whether the current site can use the paid podcast surfaces.
*
* @return bool
*/
public static function has_product_access(): bool {
if ( ! ( new Host() )->is_wpcom_platform() ) {
return self::self_hosted_has_paid_plan();
}
$blog_id = get_current_blog_id();
if ( $blog_id <= 0 ) {
return false;
}
if ( self::is_grandfathered( $blog_id ) ) {
return true;
}
return (bool) Current_Plan::supports( self::FEATURE_SLUG );
}
/**
* Drop the cached purchases lookup so the next access check re-reads
* `/upgrades`. Called when a buyer returns from checkout so a fresh plan
* unlocks the paid surfaces immediately rather than after the TTL.
*/
public static function flush_purchases_cache(): void {
delete_transient( self::PURCHASES_TRANSIENT );
self::$purchases_cache = null;
}
/**
* Whether a self-hosted Jetpack site owns a Growth (or Complete) plan.
*
* Mirrors the bundle-detection pattern used by My Jetpack's Growth/Security
* products: match purchased product slugs rather than the `podcasting`
* feature, which maps to all Jetpack sites on WordPress.com and so can't
* distinguish free from paid here.
*/
private static function self_hosted_has_paid_plan(): bool {
foreach ( self::get_site_current_purchases() as $purchase ) {
$slug = is_array( $purchase ) && isset( $purchase['product_slug'] ) ? $purchase['product_slug'] : '';
// Growth and Complete bundles unlock the paid surfaces; matched as
// prefixes so every billing term/tier counts.
foreach ( array( 'jetpack_growth', 'jetpack_complete' ) as $prefix ) {
if ( is_string( $slug ) && 0 === strpos( $slug, $prefix ) ) {
return true;
}
}
}
return false;
}
/**
* The site's current purchases from WordPress.com (`/upgrades`).
*
* Fails closed: an unreachable or malformed response returns no purchases
* and isn't written to the transient, so the next request retries rather
* than serving a stale empty result.
*
* @return array List of purchase entries (associative arrays); empty on failure.
*/
private static function get_site_current_purchases(): array {
if ( null !== self::$purchases_cache ) {
return self::$purchases_cache;
}
$cached = get_transient( self::PURCHASES_TRANSIENT );
if ( is_array( $cached ) ) {
self::$purchases_cache = $cached;
return self::$purchases_cache;
}
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/upgrades?site=%d', (int) Jetpack_Options::get_option( 'id' ) ),
'1.2',
array( 'method' => 'GET' )
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
self::$purchases_cache = array();
return self::$purchases_cache;
}
$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $decoded ) ) {
self::$purchases_cache = array();
return self::$purchases_cache;
}
// 30s: short enough that a plan change shows up quickly even if the
// checkout-return bust is missed, long enough to dedupe a page load.
set_transient( self::PURCHASES_TRANSIENT, $decoded, 30 );
self::$purchases_cache = $decoded;
return self::$purchases_cache;
}
/**
* Whether the blog is grandfathered: registered before the cutoff AND on a paid plan.
*
* @param int $blog_id Blog ID.
*/
protected static function is_grandfathered( int $blog_id ): bool {
if ( ! function_exists( 'get_blog_details' ) ) {
return false;
}
$details = get_blog_details( $blog_id );
if ( ! $details || empty( $details->registered ) ) {
return false;
}
$registered_ts = strtotime( $details->registered );
if ( false === $registered_ts || $registered_ts >= strtotime( self::GRANDFATHER_CUTOFF_DATE ) ) {
return false;
}
$plan = Current_Plan::get();
return ! empty( $plan['class'] ) && 'free' !== $plan['class'];
}
}
@@ -0,0 +1,130 @@
<?php
/**
* Main loader for the Jetpack Podcast package.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
use Automattic\Jetpack\Status\Host;
/**
* Loads Jetpack Podcast on Simple and Atomic sites. The package owns the
* podcasting experience outright.
*/
class Podcast {
const PACKAGE_VERSION = '1.3.0';
/**
* Whether the class has been initialized.
*
* @var bool
*/
private static $initialized = false;
/**
* Initialize the package.
*
* Always loads on Simple and WoA. On self-hosted Jetpack it stays dormant
* unless opted in via the `jetpack_podcast_for_the_world` filter.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
$host = new Host();
/**
* Allow the Podcast package to load on self-hosted Jetpack sites.
*
* @since 1.1.1
*
* @param bool $enabled Whether to load the package on self-hosted. Default false.
*/
$for_the_world = (bool) apply_filters( 'jetpack_podcast_for_the_world', false );
if ( ! $host->is_wpcom_simple() && ! $host->is_woa_site() && ! $for_the_world ) {
return;
}
Podcast_Episode_Block::register_hooks();
Podcast_Stats_Endpoint::init();
Podcast_Distribution_Endpoint::init();
Podcast_Settings_Endpoint::init();
Settings::register();
// Wire the RSS feed customizations (`<itunes:*>` + `<podcast:*>` tags,
// stats-tracked enclosure URLs) for the configured podcast category.
Customize_Feed::init();
Tracks::init();
if ( is_admin() ) {
Admin_Page::init();
New_Episode_Prefill::init();
}
if ( $host->is_wpcom_simple() || $host->is_woa_site() ) {
// Register the local REST routes before request-local rollout gates.
// Requests from public-api.wordpress.com may not satisfy those gates,
// but the wpcom/v2 routes still need to exist so permission and
// callback checks can handle the request.
Posts_To_Podcast_Endpoint::init();
// Posts to Podcast lives behind its own filter so the Create AI
// Podcast page can ship independently of the rest of the package.
//
// Note: no `is_admin()` guard here. The submenu must also register when
// Calypso builds its nav via the `wpcom/v2/admin-menu` REST endpoint,
// which fires `admin_menu` by loading `wp-admin/menu.php` but runs as a
// REST request where `is_admin()` is false. The hooks `init()` wires
// (`admin_menu`, `enqueue_block_editor_assets`) self-gate, so this is a
// no-op on non-admin/non-editor requests.
if ( self::is_posts_to_podcast_enabled() ) {
Create_AI_Podcast_Page::init();
}
}
}
/**
* Whether the Posts to Podcast feature (Create AI Podcast page + REST
* proxy) is enabled for the current request.
*
* Defaults to true for connected WordPress.com users, and can be flipped
* globally via the `jetpack_posts_to_podcast` filter.
*/
public static function is_posts_to_podcast_enabled() {
/**
* Master switch for the Posts to Podcast (Create AI Podcast) feature.
*
* @since 0.1.0
*
* @param bool $enabled Whether to enable Posts to Podcast.
*/
$enabled = self::is_user_connected( get_current_user_id() );
return (bool) apply_filters( 'jetpack_posts_to_podcast', $enabled );
}
/**
* Whether a user is connected to WordPress.com.
*
* @param int $user_id User ID.
* @return bool
*/
private static function is_user_connected( $user_id ) {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return true;
}
return ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
}
}
@@ -0,0 +1,437 @@
<?php
/**
* Podcast settings: option schema, sanitizers, and Jetpack Sync opt-in.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
/**
* Registers the `podcasting_*` options with their `sanitize_callback`s and
* `show_in_rest` so they keep appearing in core `/wp/v2/settings`. The dashboard
* now reads and writes them through the dedicated {@see Podcast_Settings_Endpoint}
* (`wpcom/v2/podcast/settings`); the core exposure stays for now and is removed
* in a follow-up once WPCOM's settings-controller test is decoupled.
*
* Array-shaped options merge against stored values on sanitize, not replace —
* the SPA can PATCH partial entries without losing the rest.
*/
class Settings {
/**
* Per-podcatcher hostname allowlist for `podcasting_show_urls`. `www.` is
* stripped before comparison.
*
* @var array<string, string[]>
*/
const SHOW_URL_HOSTS = array(
'pocketcasts' => array( 'pca.st', 'pocketcasts.com' ),
'apple' => array( 'podcasts.apple.com' ),
'spotify' => array( 'open.spotify.com' ),
'youtube' => array( 'youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com' ),
'amazon' => array(
'music.amazon.com',
'music.amazon.co.uk',
'music.amazon.de',
'music.amazon.co.jp',
'music.amazon.com.au',
'music.amazon.fr',
'music.amazon.ca',
'music.amazon.es',
),
'podcastindex' => array( 'podcastindex.org' ),
);
const SHOW_URL_MAX_LENGTH = 2048;
/**
* Drives `register_settings()` and the sync whitelist.
*
* @var string[]
*/
const OPTION_NAMES = array(
'podcasting_category_id',
'podcasting_title',
'podcasting_talent_name',
'podcasting_summary',
'podcasting_copyright',
'podcasting_explicit',
'podcasting_image',
'podcasting_image_id',
'podcasting_category_1',
'podcasting_category_2',
'podcasting_category_3',
'podcasting_email',
'podcasting_show_urls',
'podcasting_show_states',
);
/**
* Whether `register()` has wired its hooks.
*
* @var bool
*/
private static $registered = false;
/**
* Wire option registrations + Jetpack Sync opt-in. Idempotent.
*/
public static function register() {
if ( self::$registered ) {
return;
}
self::$registered = true;
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
add_action( 'rest_api_init', array( __CLASS__, 'register_settings' ) );
add_filter(
'jetpack_sync_options_whitelist',
static function ( $options ) {
return array_merge( $options, self::OPTION_NAMES );
}
);
}
/**
* `register_setting()` calls. Hooked on `admin_init` and `rest_api_init`.
*/
public static function register_settings() {
$media_settings = array(
array( 'podcasting_category_id', 'integer', 0, 'absint' ),
array( 'podcasting_title', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_talent_name', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_summary', 'string', '', 'sanitize_textarea_field' ),
array( 'podcasting_copyright', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_category_1', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_category_2', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_category_3', 'string', '', 'sanitize_text_field' ),
);
// Registered under WP core's `media` group to match WPCOM's legacy Media
// Settings form, so it keeps accepting these.
foreach ( $media_settings as list( $name, $type, $default, $sanitize ) ) {
register_setting(
'media',
$name,
array(
'type' => $type,
'default' => $default,
'sanitize_callback' => $sanitize,
'show_in_rest' => true,
)
);
}
register_setting(
'media',
'podcasting_image',
array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'esc_url_raw',
'show_in_rest' => array(
'schema' => array(
'type' => 'string',
'default' => '',
'format' => 'uri',
),
),
)
);
register_setting(
'media',
'podcasting_explicit',
array(
'type' => 'boolean',
'default' => false,
'sanitize_callback' => array( __CLASS__, 'sanitize_explicit' ),
'show_in_rest' => true,
)
);
// Registered under WP core's `options` group: REST-only settings that
// WPCOM never wired into the Settings API.
register_setting(
'options',
'podcasting_email',
array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_email',
'show_in_rest' => true,
)
);
register_setting(
'options',
'podcasting_image_id',
array(
'type' => 'integer',
'default' => 0,
'sanitize_callback' => 'absint',
'show_in_rest' => true,
)
);
$podcatcher_keys = array_keys( self::SHOW_URL_HOSTS );
$empty_map = array_fill_keys( $podcatcher_keys, '' );
register_setting(
'options',
'podcasting_show_urls',
array(
'type' => 'object',
'default' => array(),
'sanitize_callback' => array( __CLASS__, 'sanitize_show_urls' ),
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'default' => $empty_map,
'properties' => array_fill_keys(
$podcatcher_keys,
array(
'type' => 'string',
'format' => 'uri',
'maxLength' => self::SHOW_URL_MAX_LENGTH,
)
),
),
),
)
);
register_setting(
'options',
'podcasting_show_states',
array(
'type' => 'object',
'default' => array(),
'sanitize_callback' => array( __CLASS__, 'sanitize_show_states' ),
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'default' => $empty_map,
'properties' => array_fill_keys(
$podcatcher_keys,
array(
'type' => 'string',
'enum' => array( '', 'pending', 'active' ),
)
),
),
),
)
);
}
/**
* Stable, fully-padded settings payload for the REST endpoint. Every
* `OPTION_NAMES` key is present; the two podcatcher maps are padded to all
* known directories with empty strings so the SPA always sees a fixed shape.
*
* @return array<string, mixed>
*/
public static function get_all(): array {
$empty_map = array_fill_keys( array_keys( self::SHOW_URL_HOSTS ), '' );
$show_urls = (array) get_option( 'podcasting_show_urls', array() );
$show_states = (array) get_option( 'podcasting_show_states', array() );
return array(
'podcasting_category_id' => (int) get_option( 'podcasting_category_id', 0 ),
'podcasting_title' => (string) get_option( 'podcasting_title', '' ),
'podcasting_talent_name' => (string) get_option( 'podcasting_talent_name', '' ),
'podcasting_summary' => (string) get_option( 'podcasting_summary', '' ),
'podcasting_copyright' => (string) get_option( 'podcasting_copyright', '' ),
'podcasting_explicit' => self::sanitize_explicit( get_option( 'podcasting_explicit', false ) ),
'podcasting_image' => self::raw_show_image_url(),
'podcasting_image_id' => (int) get_option( 'podcasting_image_id', 0 ),
'podcasting_category_1' => (string) get_option( 'podcasting_category_1', '' ),
'podcasting_category_2' => (string) get_option( 'podcasting_category_2', '' ),
'podcasting_category_3' => (string) get_option( 'podcasting_category_3', '' ),
'podcasting_email' => (string) get_option( 'podcasting_email', '' ),
'podcasting_show_urls' => array_merge( $empty_map, array_intersect_key( $show_urls, $empty_map ) ),
'podcasting_show_states' => array_merge( $empty_map, array_intersect_key( $show_states, $empty_map ) ),
);
}
/**
* Per-key type map for the endpoint's update args. Type coercion only — the
* registered `sanitize_callback`s do the real validation on write, so a single
* bad field can't 400 the whole partial patch.
*
* @return array<string, array<string, mixed>>
*/
public static function rest_schema_properties(): array {
return array(
'podcasting_category_id' => array( 'type' => 'integer' ),
'podcasting_title' => array( 'type' => 'string' ),
'podcasting_talent_name' => array( 'type' => 'string' ),
'podcasting_summary' => array( 'type' => 'string' ),
'podcasting_copyright' => array( 'type' => 'string' ),
'podcasting_explicit' => array( 'type' => array( 'boolean', 'string' ) ),
'podcasting_image' => array( 'type' => 'string' ),
'podcasting_image_id' => array( 'type' => 'integer' ),
'podcasting_category_1' => array( 'type' => 'string' ),
'podcasting_category_2' => array( 'type' => 'string' ),
'podcasting_category_3' => array( 'type' => 'string' ),
'podcasting_email' => array( 'type' => 'string' ),
'podcasting_show_urls' => array( 'type' => 'object' ),
'podcasting_show_states' => array( 'type' => 'object' ),
);
}
/**
* Show cover image URL: `podcasting_image_id` resolved to its attachment
* URL when it points at an image, otherwise the raw `podcasting_image`
* option. Never Photon-routed — feed rendering applies its own resize.
*
* @return string Image URL, or '' when not configured.
*/
public static function raw_show_image_url(): string {
$image_id = (int) get_option( 'podcasting_image_id', 0 );
if ( $image_id > 0 && wp_attachment_is_image( $image_id ) ) {
$url = wp_get_attachment_url( $image_id );
if ( false !== $url ) {
return $url;
}
}
return (string) get_option( 'podcasting_image', '' );
}
/**
* `'yes'` (any case) or boolean true → true; everything else → false. The
* feed only emits true/false; the legacy `'clean'` value collapses to false
* because the WPCOM feed builder already treats it that way.
*
* @param mixed $value Raw input.
* @return bool
*/
public static function sanitize_explicit( $value ) {
if ( is_string( $value ) ) {
return in_array( strtolower( $value ), array( 'yes', 'true', '1' ), true );
}
return true === $value || 1 === $value;
}
/**
* Merge a partial show-URLs patch into the stored value. Empty string for a
* known key removes that entry; URLs failing the per-podcatcher hostname
* allowlist are silently dropped (the SPA validates the same allowlist).
*
* @param mixed $input Incoming patch.
* @return array<string, string>
*/
public static function sanitize_show_urls( $input ) {
$current = array_filter(
array_intersect_key( (array) get_option( 'podcasting_show_urls', array() ), self::SHOW_URL_HOSTS ),
static function ( $value ) {
return is_string( $value ) && '' !== $value;
}
);
if ( ! is_array( $input ) ) {
return $current;
}
foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
$value = is_string( $value ) ? trim( $value ) : '';
if ( '' === $value ) {
unset( $current[ $key ] );
continue;
}
$cleaned = self::sanitize_show_url( $key, $value );
if ( null !== $cleaned ) {
$current[ $key ] = $cleaned;
}
}
return $current;
}
/**
* Merge a partial show-states patch into the stored value. Values outside
* the allowed `'pending'`/`'active'` set are dropped; empty string clears a
* stored entry. `'active'` → `'pending'` is
* refused so a stale SPA cache can't downgrade a state that `Feed_Detection`
* promoted via real UA evidence (explicit `''` clears still work).
*
* @param mixed $input Incoming patch.
* @return array<string, string>
*/
public static function sanitize_show_states( $input ) {
$current = array_filter(
array_intersect_key( (array) get_option( 'podcasting_show_states', array() ), self::SHOW_URL_HOSTS ),
static function ( $value ) {
return is_string( $value ) && '' !== $value;
}
);
if ( ! is_array( $input ) ) {
return $current;
}
foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
$value = is_string( $value ) ? trim( $value ) : '';
if ( '' === $value ) {
unset( $current[ $key ] );
continue;
}
if ( ! in_array( $value, array( 'pending', 'active' ), true ) ) {
continue;
}
if ( 'pending' === $value && isset( $current[ $key ] ) && 'active' === $current[ $key ] ) {
continue;
}
$current[ $key ] = $value;
}
return $current;
}
/**
* Validate a URL against the per-podcatcher hostname allowlist.
*
* @param string $key Podcatcher key.
* @param string $url Candidate URL.
* @return string|null Cleaned URL, or null if the host isn't in the allowlist.
*/
private static function sanitize_show_url( $key, $url ) {
if ( ! isset( self::SHOW_URL_HOSTS[ $key ] ) ) {
return null;
}
if ( ! is_string( $url ) || strlen( $url ) > self::SHOW_URL_MAX_LENGTH ) {
return null;
}
$cleaned = esc_url_raw( $url, array( 'https' ) );
if ( '' === $cleaned ) {
return null;
}
if ( ! wp_http_validate_url( $cleaned ) ) {
return null;
}
$host = wp_parse_url( $cleaned, PHP_URL_HOST );
if ( ! is_string( $host ) || '' === $host ) {
return null;
}
$host = strtolower( $host );
if ( 0 === strpos( $host, 'www.' ) ) {
$host = substr( $host, 4 );
}
return in_array( $host, self::SHOW_URL_HOSTS[ $key ], true ) ? $cleaned : null;
}
}
@@ -0,0 +1,410 @@
<?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;
}
}
@@ -0,0 +1,110 @@
<?php
/**
* Local Jetpack-side REST proxy for podcast distribution submissions.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Client;
use Jetpack_Options;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Response;
use WP_REST_Server;
/**
* Forwards podcast distribution `wp.apiFetch` calls from the dashboard SPA to
* the wpcom-side podcast-distribution endpoints. Same rationale as
* Podcast_Stats_Endpoint: wpcom-proxy-request can't authenticate from Atomic
* origins, so we proxy server-to-server with the user's token.
*/
class Podcast_Distribution_Endpoint extends WP_REST_Controller {
use Relay_Response;
/**
* Wire up routes.
*/
public static function init() {
add_action( 'rest_api_init', array( self::class, 'register' ) );
}
/**
* Registers the REST routes on the `rest_api_init` hook.
*
* Instantiated here, rather than eagerly, so the endpoint class only loads
* on requests that reach `rest_api_init`. Static so the callback can be
* unregistered.
*/
public static function register() {
( new self() )->register_routes();
}
/**
* Register the Pocket Casts submit proxy route.
*/
public function register_routes() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'podcast-distribution';
register_rest_route(
$this->namespace,
$this->rest_base . '/pocket-casts/submit',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'submit_pocket_casts' ),
'permission_callback' => array( $this, 'permission_check' ),
)
);
}
/**
* Permission callback. Submitting a feed mutates the site's distribution
* state, so require the same edit permission the dashboard SPA itself uses.
*
* @return true|WP_Error
*/
public function permission_check() {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to submit podcasts for this site.', 'jetpack-podcast' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* POST /wpcom/v2/podcast-distribution/pocket-casts/submit — forward to wpcom.
*
* @return WP_REST_Response|WP_Error
*/
public function submit_pocket_casts() {
$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-podcast' ),
array( 'status' => 400 )
);
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/podcast-distribution/pocket-casts/submit', $blog_id ),
'2',
array(
// Pocket Casts relay can be slow; keep a generous timeout.
'method' => 'POST',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 30,
),
null,
'wpcom'
);
return $this->relay_response( $response );
}
}
@@ -0,0 +1,133 @@
<?php
/**
* Dedicated REST endpoint for `podcasting_*` site settings.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Reads and writes the `podcasting_*` options for the dashboard SPA over its own
* `wpcom/v2/podcast/settings` route. Schema and sanitizers live in {@see Settings}.
*
* Registered through the WPCOM REST API v2 plugin framework
* ({@see wpcom_rest_api_v2_load_plugin()}), so a single definition is reachable on
* Simple and WoA via the `public-api.wordpress.com` proxy and, once Podcast ships in
* the Jetpack plugin, same-origin on self-hosted sites — no per-platform relay.
*/
class Podcast_Settings_Endpoint extends WP_REST_Controller {
/**
* Wire the routes onto `rest_api_init`. The framework instantiates this once.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register the endpoint through the WPCOM REST API v2 framework. The loader
* ships with the Jetpack plugin core-api, present in every context Podcast runs
* in (Simple/WoA today, the Jetpack plugin once Podcast moves there); guarded so
* the package no-ops rather than fatals if it's somehow absent.
*/
public static function init() {
if ( function_exists( 'wpcom_rest_api_v2_load_plugin' ) ) {
wpcom_rest_api_v2_load_plugin( self::class );
}
}
/**
* Register the GET (full record) + writable (partial patch) routes.
*
* Update args only coerce top-level types — the registered `sanitize_callback`s
* do the real validation on write, so one bad field can't 400 the whole patch.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'podcast/settings',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'permission_check' ),
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => Settings::rest_schema_properties(),
),
)
);
}
/**
* Site admins only — same gate as the wp-admin Podcast dashboard.
*
* @return true|WP_Error
*/
public function permission_check() {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to manage podcast settings for this site.', 'jetpack-podcast' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* GET — the full, padded settings record.
*
* @param WP_REST_Request $request Unused.
* @return WP_REST_Response
*/
public function get_item( $request ) {
unset( $request );
return rest_ensure_response( Settings::get_all() );
}
/**
* PUT/POST/PATCH — apply a partial patch and return the full merged record.
*
* Only keys actually present in the request are written, so absent keys can
* never clobber stored values. Array-shaped options merge on sanitize.
*
* @param WP_REST_Request $request Incoming request.
* @return WP_REST_Response
*/
public function update_item( $request ) {
$saved = false;
foreach ( Settings::OPTION_NAMES as $name ) {
$value = $request->get_param( $name );
if ( null === $value ) {
continue;
}
if ( update_option( $name, $value ) ) {
$saved = true;
}
}
if ( $saved ) {
/**
* Fires after a podcast settings write changes at least one option.
*
* @since 1.1.1
*/
do_action( 'jetpack_podcast_settings_saved' );
}
return rest_ensure_response( Settings::get_all() );
}
}
@@ -0,0 +1,271 @@
<?php
/**
* Local Jetpack-side REST proxy for podcast stats.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Client;
use Jetpack_Options;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Forwards podcast stats `wp.apiFetch` calls from the dashboard SPA to the
* wpcom-side podcast-stats endpoints. Browser-side wpcom-proxy-request only
* authenticates from wpcom origins, so Atomic admin (which is the whole point
* of this proxy) needs the request to go server-to-server with the blog
* token. Simple sites pass through `Client::wpcom_json_api_request_as_user`
* via the in-process IS_WPCOM short-circuit, so the round-trip cost is negligible.
*/
class Podcast_Stats_Endpoint extends WP_REST_Controller {
use Relay_Response;
/**
* Wire up routes.
*/
public static function init() {
add_action( 'rest_api_init', array( self::class, 'register' ) );
}
/**
* Registers the REST routes on the `rest_api_init` hook.
*
* Instantiated here, rather than eagerly, so the endpoint class only loads
* on requests that reach `rest_api_init`. Static so the callback can be
* unregistered.
*/
public static function register() {
( new self() )->register_routes();
}
/**
* Register the four stats proxy routes the dashboard SPA consumes.
*/
public function register_routes() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'podcast-stats';
// Period summary (top apps/countries/episodes for a date range).
register_rest_route(
$this->namespace,
$this->rest_base,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_summary' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'from' => array(
'type' => 'string',
'required' => false,
),
'to' => array(
'type' => 'string',
'required' => false,
),
'limit' => array(
'type' => 'integer',
'required' => false,
),
),
)
);
// Period-independent overview (all-time totals, preset windows, top day).
register_rest_route(
$this->namespace,
$this->rest_base . '/overview',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_overview' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'limit' => array(
'type' => 'integer',
'required' => false,
),
),
)
);
// Plays + duration for a batch of episode post IDs.
register_rest_route(
$this->namespace,
$this->rest_base . '/episode-totals',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_episode_totals' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'post_ids' => array(
'type' => 'string',
'required' => true,
),
),
)
);
// Per-episode detail stats for a date range.
register_rest_route(
$this->namespace,
$this->rest_base . '/episode/(?P<post_id>\d+)',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_episode_detail' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'post_id' => array(
'type' => 'integer',
'required' => true,
),
'from' => array(
'type' => 'string',
'required' => false,
),
'to' => array(
'type' => 'string',
'required' => false,
),
),
)
);
}
/**
* Permission callback. Stats are surfaced in the dashboard SPA which is
* limited to users who can edit the site's posts.
*
* @return true|WP_Error
*/
public function permission_check() {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to view podcast stats for this site.', 'jetpack-podcast' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* GET /wpcom/v2/podcast-stats — period summary.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_summary( WP_REST_Request $request ) {
return $this->forward(
'podcast-stats',
array(
'from' => $request->get_param( 'from' ),
'to' => $request->get_param( 'to' ),
'limit' => $request->get_param( 'limit' ),
)
);
}
/**
* GET /wpcom/v2/podcast-stats/overview — period-independent overview.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_overview( WP_REST_Request $request ) {
return $this->forward(
'podcast-stats/overview',
array(
'limit' => $request->get_param( 'limit' ),
)
);
}
/**
* GET /wpcom/v2/podcast-stats/episode-totals — plays + duration per episode.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_episode_totals( WP_REST_Request $request ) {
return $this->forward(
'podcast-stats/episode-totals',
array(
'post_ids' => $request->get_param( 'post_ids' ),
)
);
}
/**
* GET /wpcom/v2/podcast-stats/episode/{post_id} — per-episode detail.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_episode_detail( WP_REST_Request $request ) {
$post_id = (int) $request['post_id'];
return $this->forward(
sprintf( 'podcast-stats/episode/%d', $post_id ),
array(
'from' => $request->get_param( 'from' ),
'to' => $request->get_param( 'to' ),
)
);
}
/**
* Forward a GET to `public-api.wordpress.com/wpcom/v2/sites/{blog_id}/{sub_path}`
* as the current user, dropping query args whose value is null/''.
*
* @param string $sub_path Sub-path under `/sites/{blog_id}/` (no leading slash).
* @param array $query Query args to append.
*
* @return WP_REST_Response|WP_Error
*/
private function forward( $sub_path, $query ) {
$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-podcast' ),
array( 'status' => 400 )
);
}
$query = array_filter(
$query,
static function ( $value ) {
return null !== $value && '' !== $value;
}
);
$path = sprintf( '/sites/%d/%s', $blog_id, $sub_path );
if ( ! empty( $query ) ) {
$path = add_query_arg( $query, $path );
}
$response = Client::wpcom_json_api_request_as_user(
$path,
'2',
array(
'method' => 'GET',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 15,
),
null,
'wpcom'
);
return $this->relay_response( $response );
}
}
@@ -0,0 +1,366 @@
<?php
/**
* Local Jetpack-side REST endpoint for the Posts to Podcast feature.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Client;
use Jetpack_Options;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Forwards `wp.apiFetch` calls from the wp-admin Create tab to the wpcom-side
* endpoint as the current user (the upstream endpoint requires user identity).
*/
class Posts_To_Podcast_Endpoint extends WP_REST_Controller {
use Relay_Response;
const SUPPORTED_LENGTHS = array( 'short', 'medium', 'long' );
const SUPPORTED_VOICE_PRESETS = array( 'witty', 'earnest', 'professional' );
const REST_NAMESPACE = 'wpcom/v2';
const REST_BASE = 'posts-to-podcast';
const POST_PUBLISH_PROMO_DISMISS_REST_ROUTE = 'post-publish-promo/dismiss';
/**
* Whether `init()` has wired its hooks.
*
* @var bool
*/
private static $initialized = false;
/**
* Wire up routes. Idempotent.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
$instance = new self();
add_action( 'rest_api_init', array( $instance, 'register_routes' ) );
}
/**
* Get the REST API path used by apiFetch for post-publish promo dismissal.
*
* @return string
*/
public static function get_post_publish_promo_dismiss_rest_path() {
return '/' . self::REST_NAMESPACE . '/' . self::REST_BASE . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE;
}
/**
* Register feature info, enqueue, job-status, and promo dismissal routes.
*/
public function register_routes() {
$this->namespace = self::REST_NAMESPACE;
$this->rest_base = self::REST_BASE;
register_rest_route(
$this->namespace,
$this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_feature_info' ),
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'enqueue_generation' ),
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
'args' => array(
'window' => array(
'type' => 'object',
'required' => false,
'description' => __( 'Either { unit: days|weeks|months, n: <positive int> } or { from, to } as ISO-8601 dates. Required when postIds is omitted.', 'jetpack-podcast' ),
),
'postIds' => array(
'type' => 'array',
'required' => false,
'items' => array( 'type' => 'integer' ),
'maxItems' => 25,
'description' => __( 'Explicit list of published post IDs to draw from (up to 25). Required when window is omitted.', 'jetpack-podcast' ),
),
'length' => array(
'type' => 'string',
'required' => true,
'enum' => self::SUPPORTED_LENGTHS,
'description' => __( 'Length preset id.', 'jetpack-podcast' ),
),
'voicePreset' => array(
'type' => 'string',
'required' => true,
'enum' => self::SUPPORTED_VOICE_PRESETS,
'description' => __( 'Voice preset id.', 'jetpack-podcast' ),
),
'prompt' => array(
'type' => 'string',
'required' => false,
'description' => __( 'Optional free-form instructions appended to the generation prompt.', 'jetpack-podcast' ),
),
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/jobs/(?P<job_id>\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_job_status' ),
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
'args' => array(
'job_id' => array(
'type' => 'integer',
'required' => true,
),
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/episodes',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_episodes' ),
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
'args' => array(
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'per_page' => array(
'type' => 'integer',
'default' => 5,
'minimum' => 1,
'maximum' => 50,
),
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE,
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'dismiss_post_publish_promo' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
}
/**
* Return posts that embed a `jetpack/podcast-episode` block — the surface
* this feature creates on success — newest first. Drafts and published
* posts only; trashed/auto-drafts are excluded.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response
*/
public function read_episodes( WP_REST_Request $request ) {
$page = max( 1, (int) $request->get_param( 'page' ) );
$per_page = max( 1, min( 50, (int) $request->get_param( 'per_page' ) ) );
$query = new \WP_Query(
array(
'post_type' => 'post',
'post_status' => array( 'draft', 'publish' ),
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'date',
'order' => 'DESC',
'update_post_term_cache' => false,
'meta_query' => array(
array(
'key' => 'posts_to_podcast_metadata',
'compare' => 'EXISTS',
),
),
)
);
$items = array();
foreach ( $query->posts as $post ) {
$raw_meta = get_post_meta( $post->ID, 'posts_to_podcast_metadata', true );
$meta = is_string( $raw_meta ) ? json_decode( $raw_meta, true ) : null;
$audio = ( is_array( $meta ) && isset( $meta['audio'] ) && is_array( $meta['audio'] ) ) ? $meta['audio'] : array();
$title = wp_strip_all_tags(
html_entity_decode( (string) get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' )
);
if ( '' === trim( $title ) ) {
// translators: Fallback shown in the Generated podcasts list when a draft has an empty title.
$title = __( '(no title)', 'jetpack-podcast' );
}
$items[] = array(
'id' => $post->ID,
'title' => $title,
'status' => $post->post_status,
'date' => mysql2date( 'c', $post->post_date_gmt, false ),
'editUrl' => get_edit_post_link( $post->ID, 'raw' ),
'mediaUrl' => isset( $audio['url'] ) ? esc_url_raw( (string) $audio['url'] ) : '',
'mediaType' => 'audio',
'mediaMime' => isset( $audio['mimeType'] ) ? (string) $audio['mimeType'] : '',
'duration' => isset( $audio['durationSeconds'] ) ? (int) round( (float) $audio['durationSeconds'] ) : 0,
);
}
$total = (int) $query->found_posts;
$total_pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 0;
return rest_ensure_response(
array(
'items' => $items,
'total' => $total,
'page' => $page,
'perPage' => $per_page,
'totalPages' => $total_pages,
)
);
}
/**
* Persist post-publish promo dismissal for the current user and site.
*
* @return WP_REST_Response
*/
public function dismiss_post_publish_promo() {
update_user_option( get_current_user_id(), Create_AI_Podcast_Page::POST_PUBLISH_PROMO_DISMISSED_OPTION, 1 );
return rest_ensure_response(
array(
'dismissed' => true,
)
);
}
/**
* Forward GET to the wpcom-side endpoint and return feature info
* (remaining credits, plan, supported presets).
*
* @return WP_REST_Response|WP_Error
*/
public function read_feature_info() {
$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-podcast' ), array( 'status' => 400 ) );
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
'2',
array(
'method' => 'GET',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 15,
),
null,
'wpcom'
);
return $this->relay_response( $response );
}
/**
* 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-podcast' ), array( 'status' => 400 ) );
}
$body_payload = array(
'length' => $request->get_param( 'length' ),
'voicePreset' => $request->get_param( 'voicePreset' ),
);
$window = $request->get_param( 'window' );
if ( null !== $window ) {
$body_payload['window'] = $window;
}
$post_ids = $request->get_param( 'postIds' );
if ( is_array( $post_ids ) && ! empty( $post_ids ) ) {
$body_payload['postIds'] = array_values( array_map( 'intval', $post_ids ) );
}
$prompt = $request->get_param( 'prompt' );
if ( is_string( $prompt ) && '' !== $prompt ) {
$body_payload['prompt'] = $prompt;
}
if ( ! isset( $body_payload['window'] ) && ! isset( $body_payload['postIds'] ) ) {
return new WP_Error( 'missing-source', __( 'One of window or postIds is required.', 'jetpack-podcast' ), array( 'status' => 400 ) );
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
'2',
array(
'method' => 'POST',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 30,
),
wp_json_encode( $body_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
'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-podcast' ), array( 'status' => 400 ) );
}
$job_id = (int) $request['job_id'];
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/posts-to-podcast/jobs/%d', $blog_id, $job_id ),
'2',
array(
'method' => 'GET',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 15,
),
null,
'wpcom'
);
return $this->relay_response( $response );
}
}
@@ -0,0 +1,51 @@
<?php
/**
* Helper for the Posts to Podcast feature.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
/**
* Gating + permission helpers for the Posts to Podcast REST endpoint.
*/
class Posts_To_Podcast_Helper {
/**
* Whether the Posts to Podcast feature is active for the current request.
*
* @return bool
*/
public static function is_enabled() {
/**
* Filter to allow disabling the Posts to Podcast feature on a per-site basis.
* Defaults to true wherever the podcast package is active; flip this to false
* to hide the section during a staged rollout without disabling the package.
*
* @since 0.1.0
*
* @param bool $enabled Whether the feature is enabled. Default true.
*/
return (bool) apply_filters( 'jetpack_posts_to_podcast_is_enabled', true );
}
/**
* Permission callback for the local proxy REST endpoint.
*
* @param \WP_REST_Request $request Full details about the request.
*
* @return true|\WP_Error
*/
public static function get_status_permission_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! current_user_can( 'edit_posts' ) ) {
return new \WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to use this feature on this site.', 'jetpack-podcast' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
}
@@ -0,0 +1,46 @@
<?php
/**
* Shared helper for the podcast REST proxy endpoints: relays a
* `Connection\Client` response back to the local REST client.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use WP_Error;
use WP_REST_Response;
/**
* Used by the wpcom/v2 proxy controllers (Podcast_Stats_Endpoint,
* Podcast_Distribution_Endpoint, Posts_To_Podcast_Endpoint) to round-trip the
* upstream wpcom response: preserves status code and decodes the JSON body so
* `apiFetch` on the client surfaces 4xx/5xx errors with their `{code, message}`
* payloads intact.
*/
trait Relay_Response {
/**
* Relay an upstream Connection\Client response back to the local REST client.
* Preserves the upstream HTTP status code so 4xx/5xx mappings flow through.
*
* @param array|WP_Error $response The raw response from Connection\Client.
*
* @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;
}
}
@@ -0,0 +1,489 @@
<?php
/**
* Adds podcast tags + tracked enclosure URLs to the RSS feed for the
* configured podcast category.
*
* @package automattic/jetpack-podcast
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\Podcast\Feed;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Podcast\Settings;
use WP_Post;
/**
* Hooks into RSS2 rendering when the current request is the podcast category
* feed, adding `<itunes:*>` + `<podcast:*>` tags at channel and item level
* and rewriting `<enclosure>` URLs through the WPCOM stats endpoint.
*/
class Customize_Feed {
/**
* Whether `init()` has wired its hooks.
*
* @var bool
*/
private static $registered = false;
/**
* Wire the late-binding `wp` action that decides whether to register the
* feed-modification hooks for this request. Idempotent.
*/
public static function init() {
if ( self::$registered ) {
return;
}
self::$registered = true;
add_action( 'wp', array( __CLASS__, 'maybe_register_feed_hooks' ) );
// `the_posts` fires during query execution — before the `wp` action —
// so it has to be registered up-front and self-gated to the podcast
// feed query, rather than wired conditionally in `maybe_register_feed_hooks`.
add_filter( 'the_posts', array( __CLASS__, 'filter_posts_with_enclosure' ), 10, 2 );
}
/**
* Register the RSS2 hooks if this request is the configured podcast feed.
* Also fires `Feed_Detection` while we're here — same gating, no need to
* walk the post query twice.
*/
public static function maybe_register_feed_hooks() {
if ( ! is_feed() ) {
return;
}
$category_id = self::resolve_category_id();
if ( 0 === $category_id || ! is_category( $category_id ) ) {
return;
}
// Strip channel-level tags that conflict with the iTunes-compliant
// header: blavatar / site-icon `<image>` duplicates `<itunes:image>`,
// and `<cloud …/>` from rsscloud isn't part of the podcast spec.
remove_action( 'rss2_head', 'rss2_blavatar' );
remove_action( 'rss2_head', 'rss2_site_icon' );
remove_action( 'rss2_head', 'rsscloud_add_rss_cloud_element' );
add_action( 'rss2_ns', array( __CLASS__, 'output_namespaces' ) );
add_filter( 'wp_title_rss', array( __CLASS__, 'feed_title' ) );
add_filter( 'bloginfo_rss', array( __CLASS__, 'feed_description' ), 10, 2 );
add_action( 'rss2_head', array( __CLASS__, 'output_channel_tags' ) );
add_action( 'rss2_item', array( __CLASS__, 'output_item_tags' ) );
add_filter( 'rss_enclosure', array( __CLASS__, 'rewrite_enclosure' ) );
// Prune RSS chrome that podcatchers don't read. Cuts payload size and
// keeps incidental post data (body content, gravatar URLs, image EXIF,
// comments metadata) out of a feed whose only job is to deliver
// podcast episode metadata + the audio enclosure.
//
// - option_rss_use_excerpt -> suppresses content:encoded (full post body, incl. EXIF in image attrs).
// - comments_open + get_comments_number -> together suppress per-item comments / wfw:commentRss / slash:comments.
// - the_category_rss -> suppresses per-item category tags (channel itunes:category is the podcatcher signal).
// - removing wpcom mrss.php hooks -> suppresses media:content for author gravatar + post images.
add_filter( 'option_rss_use_excerpt', '__return_true' );
add_filter( 'comments_open', '__return_false' );
add_filter( 'get_comments_number', '__return_zero' );
add_filter( 'the_category_rss', '__return_empty_string' );
remove_action( 'rss2_item', 'mrss_item', 10 );
remove_action( 'rss2_item', 'mrss_news_item' );
Feed_Detection::detect_and_record();
}
/**
* Add iTunes and Podcasting 2.0 XML namespaces to the `<rss>` open tag.
*/
public static function output_namespaces() {
echo "\n\t" . 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"' . "\n";
echo "\t" . 'xmlns:podcast="https://podcastindex.org/namespace/1.0"' . "\n";
}
/**
* Override the feed title with `podcasting_title`, falling back to
* `Blog Name » Category Name`.
*
* @param string $title Existing title.
* @return string
*/
public static function feed_title( $title ) {
$override = (string) get_option( 'podcasting_title', '' );
if ( '' !== $override ) {
return esc_xml( $override );
}
$category = get_category( self::resolve_category_id() );
if ( $category && ! is_wp_error( $category ) ) {
return esc_xml( get_bloginfo( 'name' ) ) . ' &#187; ' . esc_xml( $category->name );
}
return esc_xml( $title );
}
/**
* Replace the `bloginfo_rss('description')` value with `podcasting_summary`.
*
* `bloginfo_rss()` echoes the filter return value directly, so we strip and
* escape here — matches the channel-level `<itunes:summary>` treatment and
* keeps stray markup in the option from leaking into `<description>`.
*
* @param string $value Existing value.
* @param string $field Field being requested.
* @return string
*/
public static function feed_description( $value, $field ) {
if ( 'description' !== $field ) {
return $value;
}
return esc_xml( wp_strip_all_tags( (string) get_option( 'podcasting_summary', '' ) ) );
}
/**
* Channel-level podcast tags (rss2_head).
*/
public static function output_channel_tags() {
$summary = (string) get_option( 'podcasting_summary', '' );
if ( '' !== $summary ) {
echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $summary ) ) . "</itunes:summary>\n";
}
$author = (string) get_option( 'podcasting_talent_name', '' );
if ( '' !== $author ) {
echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
}
$email = wp_strip_all_tags( (string) get_option( 'podcasting_email', '' ) );
if ( '' !== $email ) {
echo '<itunes:owner><itunes:email>' . esc_xml( $email ) . "</itunes:email></itunes:owner>\n";
}
$copyright = (string) get_option( 'podcasting_copyright', '' );
if ( '' !== $copyright ) {
echo '<copyright>' . esc_xml( wp_strip_all_tags( $copyright ) ) . "</copyright>\n";
}
/**
* Explicit content flag
*/
echo '<itunes:explicit>' . esc_html( self::explicit_string() ) . "</itunes:explicit>\n";
$image = self::show_image_url();
if ( '' !== $image ) {
echo '<itunes:image href="' . esc_url( $image ) . '" />' . "\n";
}
echo self::category_tag( (string) get_option( 'podcasting_category_1', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
echo self::category_tag( (string) get_option( 'podcasting_category_2', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
echo self::category_tag( (string) get_option( 'podcasting_category_3', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
}
/**
* Item-level podcast tags (rss2_item).
*/
public static function output_item_tags() {
global $post;
if ( ! $post instanceof WP_Post ) {
return;
}
$author = get_the_author();
if ( '' === $author ) {
$author = (string) get_option( 'podcasting_talent_name', '' );
}
if ( '' !== $author ) {
echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
}
// Re-applying `the_excerpt_rss` so `<itunes:summary>` matches whatever
// the item's `<description>` ends up emitting — `get_the_excerpt()`
// doesn't run the filter chain itself.
$excerpt = (string) apply_filters( 'the_excerpt_rss', get_the_excerpt() );
if ( '' !== $excerpt ) {
echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $excerpt ) ) . "</itunes:summary>\n";
}
// Per-item cover art: prefer the block's `coverArt`, fall back to the
// post's featured image. Either way, photon-resize to 3000×3000 to
// honour Apple's square-cover requirement. When neither is present
// the channel-level `<itunes:image>` applies as default per spec.
$attrs = Episode_Block_Tags::get_block_attrs( $post );
$cover_url = isset( $attrs['coverArt']['url'] ) ? trim( (string) $attrs['coverArt']['url'] ) : '';
$item_image = '' !== $cover_url ? self::maybe_photon( $cover_url ) : self::episode_image_url( $post->ID );
if ( '' !== $item_image ) {
echo '<itunes:image href="' . esc_url( $item_image ) . '" />' . "\n";
}
// Block-driven iTunes + Podcasting 2.0 tags. Legacy audio posts
// without the block contribute nothing — they keep their pre-block
// behavior intact aside from the cover art handled above.
if ( ! empty( $attrs ) ) {
Episode_Block_Tags::render_from_attrs( $attrs );
}
}
/**
* Rewrite the enclosure URL through the WPCOM stats endpoint and append
* `<itunes:duration>` when resolvable. Duration is looked up against the
* *original* attachment URL — the stats URL is synthetic.
*
* @param string $enclosure Generated enclosure markup.
* @return string
*/
public static function rewrite_enclosure( $enclosure ) {
global $post;
if ( ! preg_match( '/url="([^"]*)"/i', $enclosure, $match ) ) {
return $enclosure;
}
$original_url = $match[1];
$post_obj = $post instanceof WP_Post ? $post : null;
/**
* Whether to rewrite the enclosure through the WPCOM stats endpoint.
* Token-gated feeds (notably WPCOM's `private-podcasts.php`) opt out
* — the stats URL is a deterministic public endpoint that would
* bypass any token gating on the feed itself.
*
* @param bool $enable Default true.
* @param WP_Post|null $post The post being rendered.
*/
$enable = (bool) apply_filters( 'wpcom_podcasting_enable_play_tracking', true, $post_obj );
// Skip rewrite for externally hosted enclosures — the stats endpoint 404s anything that isn't a local attachment.
$attachment_id = attachment_url_to_postid( $original_url );
if ( null !== $post_obj && $enable && $attachment_id > 0 ) {
// `null` when the site isn't connected; passed through so the filter can still inject a value.
$default_blog_id = Connection_Manager::get_site_id( true );
/**
* Override the blog ID baked into the stats URL.
*
* @param int|null $blog_id Default Jetpack connection site ID, or null when unavailable.
* @param WP_Post $post The post being rendered.
*/
$blog_id = (int) apply_filters( 'wpcom_podcasting_tracked_blog_id', $default_blog_id, $post_obj );
// Bail when we can't resolve a real blog ID — emit the original URL rather than a guaranteed-404 stats URL.
if ( $blog_id > 0 ) {
$stats_url = self::build_stats_url( $blog_id, (int) $post_obj->ID, $original_url );
$enclosure = preg_replace_callback(
'/url="[^"]*"/i',
/**
* Replace the matched `url="…"` attribute with the stats URL.
* `$matches` is required by `preg_replace_callback`'s callable
* signature but ignored — we always emit the same value.
*
* @param array $matches Regex matches.
* @return string
*/
static function ( array $matches ) use ( $stats_url ) {
unset( $matches );
return 'url="' . esc_url( $stats_url ) . '"';
},
$enclosure,
1
);
}
}
if ( 0 === $attachment_id ) {
return $enclosure;
}
$metadata = wp_get_attachment_metadata( $attachment_id );
$duration = is_array( $metadata ) ? absint( $metadata['length'] ?? 0 ) : 0;
return 0 === $duration
? $enclosure
: $enclosure . '<itunes:duration>' . $duration . "</itunes:duration>\n";
}
/**
* A podcast item without an enclosure is invalid per Apple's spec and can
* take down the whole submission. The `enclosure` post meta is what
* `rss_enclosure()` reads, so it's the authoritative signal here too.
*
* @param WP_Post[] $posts Posts about to be looped over.
* @param \WP_Query $query Query that produced them.
* @return WP_Post[]
*/
public static function filter_posts_with_enclosure( $posts, $query ) {
if ( ! $query->is_main_query() || ! $query->is_feed() || ! $query->is_category() ) {
return $posts;
}
$category_id = self::resolve_category_id();
if ( 0 === $category_id ) {
return $posts;
}
$queried = $query->get_queried_object();
if ( ! $queried || ! isset( $queried->term_id ) || (int) $queried->term_id !== $category_id ) {
return $posts;
}
return array_values(
array_filter(
$posts,
static function ( $post ) {
return $post instanceof WP_Post
&& ! empty( get_post_meta( $post->ID, 'enclosure', false ) );
}
)
);
}
/**
* Stored explicit value, normalized to the `'true'`/`'false'` strings the
* iTunes spec requires. Reuses `Settings::sanitize_explicit`
* so legacy `'yes'`/`'no'`/`'clean'` and modern boolean storage both work.
*
* @return string
*/
public static function explicit_string(): string {
return Settings::sanitize_explicit( get_option( 'podcasting_explicit', false ) ) ? 'true' : 'false';
}
/**
* Show-level cover image URL — `Settings::raw_show_image_url()` routed
* through Photon at 3000×3000 when available.
*
* @return string
*/
private static function show_image_url(): string {
$url = Settings::raw_show_image_url();
return '' === $url ? '' : self::maybe_photon( $url );
}
/**
* Build the WPCOM stats URL for a given episode. The endpoint redirects
* to the audio file after recording the play — the package never serves
* it, only points at it. Audio extensions outside the recognized set
* fall back to `mp3` to keep the URL shape uniform (matches the Podtrac
* / Megaphone / Art19 convention).
*
* @param int $blog_id WPCOM blog ID (Atomic should override via the
* `wpcom_podcasting_tracked_blog_id` filter).
* @param int $post_id Episode post ID.
* @param string $original_url Original enclosure URL — extension is pulled from here.
* @return string
*/
private static function build_stats_url( int $blog_id, int $post_id, string $original_url ): string {
$path = (string) wp_parse_url( $original_url, PHP_URL_PATH );
$ext = (string) preg_replace( '/[^a-z0-9]/', '', strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ) );
if ( ! in_array( $ext, array( 'mp3', 'm4a', 'm4b', 'aac', 'ogg', 'oga', 'opus', 'wav', 'flac', 'mp4', 'm4v', 'mov' ), true ) ) {
$ext = 'mp3';
}
return sprintf(
'https://public-api.wordpress.com/wpcom/v2/sites/%d/podcast-play/%d.%s',
$blog_id,
$post_id,
$ext
);
}
/**
* Resolve the configured podcast category ID. Prefers the numeric
* `podcasting_category_id`, falling back to a slug lookup against the
* legacy `podcasting_archive` option — older sites pre-date numeric
* storage and only have the slug. Returns 0 when neither resolves.
*
* A numeric ID whose term was deleted means "not configured" — the slug
* is not consulted in that case.
*
* @return int
*/
public static function resolve_category_id(): int {
$category_id = (int) get_option( 'podcasting_category_id', 0 );
if ( $category_id > 0 ) {
$category = get_category( $category_id );
return ( $category && ! is_wp_error( $category ) && isset( $category->term_id ) ) ? (int) $category->term_id : 0;
}
$slug = (string) get_option( 'podcasting_archive', '' );
if ( '' === $slug ) {
return 0;
}
$term = get_term_by( 'slug', $slug, 'category' );
return ( $term && ! is_wp_error( $term ) && isset( $term->term_id ) ) ? (int) $term->term_id : 0;
}
/**
* Episode-level image URL — the post's featured image, Photon-resized,
* or `''` when no featured image is set. Used as the fallback per-item
* cover when the block doesn't supply its own.
*
* @param int $post_id Episode post ID.
* @return string
*/
private static function episode_image_url( int $post_id ): string {
if ( ! has_post_thumbnail( $post_id ) ) {
return '';
}
$src = wp_get_attachment_image_src( get_post_thumbnail_id( $post_id ), 'full' );
if ( ! is_array( $src ) || empty( $src[0] ) ) {
return '';
}
return self::maybe_photon( $src[0] );
}
/**
* Route through Photon at exactly 3000×3000 so the feed always serves a
* square cover, regardless of the source aspect ratio. `resize` center-crops
* (unlike `fit`, which only constrains within the box); Apple's spec wants
* 14003000 px square art and rejects non-square covers.
*
* @param string $url Image URL.
* @return string
*/
public static function maybe_photon( string $url ): string {
if ( ! function_exists( 'jetpack_photon_url' ) ) {
return $url;
}
// @phan-suppress-next-line PhanUndeclaredFunction -- Provided by Jetpack's Photon module at runtime; guarded by `function_exists` above.
return (string) jetpack_photon_url( $url, array( 'resize' => '3000,3000' ), 'https' );
}
/**
* Build a single `<itunes:category>` tag from a stored option value. The
* stored format is one of:
* - `''` (no category)
* - `'Foo'` → single category
* - `'Foo,Bar'` → category Foo with subcategory Bar
*
* Includes a back-compat translation pass for a few legacy values that were
* stored in non-canonical shapes before validation tightened.
*
* @param string $stored Raw option value.
* @return string Empty string if no category, otherwise an XML fragment.
*/
public static function category_tag( string $stored ): string {
static $legacy_aliases = array(
'Education,Education' => 'Education',
'Education,Education Technology' => 'Education,Educational Technology',
'Tech News' => 'Technology,Tech News',
'Sports &amp; Recreation,Technology' => 'Technology',
'Sports &amp; Recreation,Gadgets' => 'Technology,Gadgets',
'Sports,Football' => 'Sports,American Football',
'Sports,Soccer' => 'Sports,Football (Soccer)',
);
$category = $legacy_aliases[ $stored ] ?? $stored;
if ( '' === $category ) {
return '';
}
// `ent2ncr()` normalises named HTML entities (e.g. `&nbsp;`, `&copy;`) into
// numeric character references so an attribute value containing them stays
// well-formed XML after esc_attr().
$splits = explode( ',', $category );
if ( 2 === count( $splits ) ) {
return '<itunes:category text="' . ent2ncr( esc_attr( $splits[0] ) ) . '">' . "\n"
. "\t" . '<itunes:category text="' . ent2ncr( esc_attr( $splits[1] ) ) . '" />' . "\n"
. "</itunes:category>\n";
}
return '<itunes:category text="' . ent2ncr( esc_attr( $category ) ) . '" />' . "\n";
}
}
@@ -0,0 +1,340 @@
<?php
/**
* Emits iTunes + Podcasting 2.0 item-level tags sourced from the
* `jetpack/podcast-episode` block's attrs.
*
* @package automattic/jetpack-podcast
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\Podcast\Feed;
use WP_Post;
/**
* Block-attr → XML translation for a single episode. Kept separate from
* `Customize_Feed` (which only wires rss2_* hooks) so the mapping logic stays
* focused and independently testable.
*/
class Episode_Block_Tags {
/**
* Emit item-level tags for a post if it contains a podcast-episode block.
* Posts without the block contribute nothing — legacy audio items keep
* their pre-block behavior intact.
*
* @param WP_Post $post Episode post.
*/
public static function render( WP_Post $post ): void {
$attrs = self::get_block_attrs( $post );
if ( empty( $attrs ) ) {
return;
}
self::render_from_attrs( $attrs );
}
/**
* Testable seam — emit tags for a literal attrs array, skipping the block
* parse. Each emit is independent and no-ops on missing/blank values.
*
* @param array<string, mixed> $attrs Block attrs.
*/
public static function render_from_attrs( array $attrs ): void {
self::emit_episode_number( $attrs );
self::emit_season_number( $attrs );
self::emit_episode_type( $attrs );
self::emit_explicit_override( $attrs );
self::emit_transcript( $attrs );
self::emit_chapters( $attrs );
self::emit_location( $attrs );
self::emit_license( $attrs );
self::emit_people( $attrs );
self::emit_soundbites( $attrs );
self::emit_alternate_enclosures( $attrs );
}
/**
* `<podcast:chapters url="…" type="…" />` from the block's `chaptersUrl`
* / `chaptersType` attrs. Skip if no URL is set.
*
* @param array $attrs Block attrs.
*/
private static function emit_chapters( array $attrs ): void {
$url = isset( $attrs['chaptersUrl'] ) ? trim( (string) $attrs['chaptersUrl'] ) : '';
if ( '' === $url ) {
return;
}
$type = isset( $attrs['chaptersType'] ) ? (string) $attrs['chaptersType'] : 'application/json+chapters';
echo '<podcast:chapters url="' . esc_url( $url ) . '" type="' . esc_attr( $type ) . '" />' . "\n";
}
/**
* Extract attrs from the first `jetpack/podcast-episode` block in the
* post's content. Returns an empty array if no such block exists.
*
* First-wins: a post containing multiple `jetpack/podcast-episode` blocks
* is semantically odd (one item = one episode) so we don't try to merge.
*
* @param WP_Post $post Episode post.
* @return array<string, mixed>
*/
public static function get_block_attrs( WP_Post $post ): array {
// Skip the parse_blocks() regex pass entirely if our specific block
// marker isn't even in the content — tighter than has_blocks(), which
// only checks for `<!-- wp:`.
if ( false === strpos( $post->post_content, '<!-- wp:jetpack/podcast-episode' ) ) {
return array();
}
foreach ( parse_blocks( $post->post_content ) as $block ) {
if ( isset( $block['blockName'] ) && 'jetpack/podcast-episode' === $block['blockName'] ) {
return isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
}
}
return array();
}
/**
* `<itunes:episode>` + `<podcast:episode>` for positive integer values.
*
* @param array $attrs Block attrs.
*/
private static function emit_episode_number( array $attrs ): void {
$value = $attrs['episodeNumber'] ?? 0;
if ( (int) $value <= 0 ) {
return;
}
echo '<itunes:episode>' . (int) $value . "</itunes:episode>\n";
echo '<podcast:episode>' . (int) $value . "</podcast:episode>\n";
}
/**
* `<itunes:season>` + `<podcast:season>` for positive integer values.
*
* @param array $attrs Block attrs.
*/
private static function emit_season_number( array $attrs ): void {
$value = $attrs['seasonNumber'] ?? 0;
if ( (int) $value <= 0 ) {
return;
}
echo '<itunes:season>' . (int) $value . "</itunes:season>\n";
echo '<podcast:season>' . (int) $value . "</podcast:season>\n";
}
/**
* `<itunes:episodeType>` for `trailer` / `bonus`. `full` is Apple's
* default and emitting it for every item would be noise.
*
* @param array $attrs Block attrs.
*/
private static function emit_episode_type( array $attrs ): void {
$value = isset( $attrs['episodeType'] ) ? (string) $attrs['episodeType'] : '';
if ( ! in_array( $value, array( 'trailer', 'bonus' ), true ) ) {
return;
}
echo '<itunes:episodeType>' . esc_xml( $value ) . "</itunes:episodeType>\n";
}
/**
* `<itunes:explicit>` only when the per-episode value differs from the
* channel default — emitting a matching value on every item is noisy
* and redundant per Apple's spec.
*
* @param array $attrs Block attrs.
*/
private static function emit_explicit_override( array $attrs ): void {
if ( ! array_key_exists( 'explicit', $attrs ) ) {
return;
}
$item_value = (bool) $attrs['explicit'] ? 'true' : 'false';
$channel_value = Customize_Feed::explicit_string();
if ( $item_value === $channel_value ) {
return;
}
echo '<itunes:explicit>' . esc_html( $item_value ) . "</itunes:explicit>\n";
}
/**
* `<podcast:transcript url="…" type="…" />`. Type is validated against
* the enum from `block.json` so a hand-edited attr can't smuggle in an
* arbitrary MIME type.
*
* @param array $attrs Block attrs.
*/
private static function emit_transcript( array $attrs ): void {
$url = isset( $attrs['transcriptUrl'] ) ? (string) $attrs['transcriptUrl'] : '';
if ( '' === trim( $url ) ) {
return;
}
$type = isset( $attrs['transcriptType'] ) ? (string) $attrs['transcriptType'] : 'text/vtt';
$allowed_types = array( 'text/vtt', 'text/html', 'application/srt', 'application/json' );
if ( ! in_array( $type, $allowed_types, true ) ) {
$type = 'text/vtt';
}
echo '<podcast:transcript url="' . esc_url( $url ) . '" type="' . esc_attr( $type ) . "\" />\n";
}
/**
* `<podcast:location name="…" />`. Geo coordinates aren't captured by
* the block yet, so we emit name-only for v1.
*
* @param array $attrs Block attrs.
*/
private static function emit_location( array $attrs ): void {
$name = isset( $attrs['locationName'] ) ? trim( (string) $attrs['locationName'] ) : '';
if ( '' === $name ) {
return;
}
echo '<podcast:location>' . esc_xml( $name ) . "</podcast:location>\n";
}
/**
* `<podcast:license>` — name as text content, optional `url` attribute.
* If only the URL is set we still need a name for valid markup, so skip.
*
* @param array $attrs Block attrs.
*/
private static function emit_license( array $attrs ): void {
$name = isset( $attrs['license'] ) ? trim( (string) $attrs['license'] ) : '';
$url = isset( $attrs['licenseUrl'] ) ? trim( (string) $attrs['licenseUrl'] ) : '';
if ( '' === $name ) {
return;
}
if ( '' === $url ) {
echo '<podcast:license>' . esc_xml( $name ) . "</podcast:license>\n";
return;
}
echo '<podcast:license url="' . esc_url( $url ) . '">' . esc_xml( $name ) . "</podcast:license>\n";
}
/**
* One `<podcast:person>` per entry. Lenient on role/group — the spec
* defines a taxonomy but most validators only warn on unknown values;
* passing them through keeps editor freedom and matches the v1 plan.
*
* @param array $attrs Block attrs.
*/
private static function emit_people( array $attrs ): void {
if ( empty( $attrs['people'] ) || ! is_array( $attrs['people'] ) ) {
return;
}
foreach ( $attrs['people'] as $person ) {
if ( ! is_array( $person ) ) {
continue;
}
$name = isset( $person['name'] ) ? trim( (string) $person['name'] ) : '';
if ( '' === $name ) {
continue;
}
$tag = '<podcast:person';
if ( ! empty( $person['role'] ) ) {
$tag .= ' role="' . esc_attr( (string) $person['role'] ) . '"';
}
if ( ! empty( $person['group'] ) ) {
$tag .= ' group="' . esc_attr( (string) $person['group'] ) . '"';
}
if ( ! empty( $person['href'] ) ) {
$tag .= ' href="' . esc_url( (string) $person['href'] ) . '"';
}
if ( ! empty( $person['img'] ) ) {
$tag .= ' img="' . esc_url( (string) $person['img'] ) . '"';
}
$tag .= '>' . esc_xml( $name ) . "</podcast:person>\n";
echo $tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
}
}
/**
* One `<podcast:soundbite>` per entry. Order is preserved — the P2.0
* spec doesn't mandate sorting and editors might intend a specific
* sequence.
*
* @param array $attrs Block attrs.
*/
private static function emit_soundbites( array $attrs ): void {
if ( empty( $attrs['soundbites'] ) || ! is_array( $attrs['soundbites'] ) ) {
return;
}
foreach ( $attrs['soundbites'] as $soundbite ) {
if ( ! is_array( $soundbite )
|| ! isset( $soundbite['startTime'] )
|| ! isset( $soundbite['duration'] )
) {
continue;
}
$start = (float) $soundbite['startTime'];
$duration = (float) $soundbite['duration'];
if ( $duration <= 0 ) {
continue;
}
$title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : '';
$tag = '<podcast:soundbite startTime="' . esc_attr( (string) $start )
. '" duration="' . esc_attr( (string) $duration ) . '"';
if ( '' === $title ) {
$tag .= " />\n";
} else {
$tag .= '>' . esc_xml( $title ) . "</podcast:soundbite>\n";
}
echo $tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
}
}
/**
* One `<podcast:alternateEnclosure>` per entry, wrapping nested
* `<podcast:source>` children. The block currently stores each entry as a
* single URL with type/bitrate metadata, but the spec models a list of
* sources — accept both shapes so a future block change doesn't need a
* second pass here.
*
* @param array $attrs Block attrs.
*/
private static function emit_alternate_enclosures( array $attrs ): void {
if ( empty( $attrs['alternateEnclosures'] ) || ! is_array( $attrs['alternateEnclosures'] ) ) {
return;
}
foreach ( $attrs['alternateEnclosures'] as $alt ) {
if ( ! is_array( $alt ) ) {
continue;
}
// `type` is required per spec; skip the entry rather than emit invalid markup.
$type = isset( $alt['type'] ) ? trim( (string) $alt['type'] ) : '';
if ( '' === $type ) {
continue;
}
$sources = array();
if ( ! empty( $alt['sources'] ) && is_array( $alt['sources'] ) ) {
foreach ( $alt['sources'] as $src ) {
if ( is_array( $src ) && ! empty( $src['uri'] ) ) {
$sources[] = (string) $src['uri'];
} elseif ( is_string( $src ) && '' !== $src ) {
$sources[] = $src;
}
}
} elseif ( ! empty( $alt['url'] ) ) {
$sources[] = (string) $alt['url'];
}
if ( empty( $sources ) ) {
continue;
}
$opener = '<podcast:alternateEnclosure type="' . esc_attr( $type ) . '"';
if ( ! empty( $alt['length'] ) ) {
$opener .= ' length="' . (int) $alt['length'] . '"';
}
if ( ! empty( $alt['bitrate'] ) ) {
$opener .= ' bitrate="' . (int) $alt['bitrate'] . '"';
}
$opener .= ">\n";
echo $opener; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
foreach ( $sources as $uri ) {
echo "\t<podcast:source uri=\"" . esc_url( $uri ) . "\" />\n";
}
echo "</podcast:alternateEnclosure>\n";
}
}
}
@@ -0,0 +1,108 @@
<?php
/**
* Records when podcast directory crawlers fetch the feed.
*
* @package automattic/jetpack-podcast
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\Podcast\Feed;
use Automattic\Jetpack\Podcast\Settings;
/**
* Promotes an existing pending podcatcher `podcasting_show_states` entry to
* `'active'` the first time we see its UA fetch the feed. Idempotent
* thereafter.
*/
class Feed_Detection {
/**
* UA needle → podcatcher slug. First match wins, so order matters: more
* specific brand needles (e.g. `GooglePodcasts`, `YouTubeMusic`) come
* before generic ones (e.g. Apple's `Podcasts/`) that they share a
* substring with.
*
* Cross-referenced against opawg/user-agents-v2 (the OPAWG community
* dataset used by Podtrac, Megaphone, Art19, Chartable). When directories
* change UAs or new ones emerge, sync the relevant entries here:
* https://github.com/opawg/user-agents-v2/blob/master/src/{apps,bots}.json
*
* @var array<string, string>
*/
private const NEEDLES = array(
// YouTube / Google Podcasts — listed first so `GooglePodcasts/`
// doesn't get caught by Apple's broader `Podcasts/` needle below.
'Google-Podcast' => 'youtube',
'YouTube-Podcast' => 'youtube',
'GooglePodcasts' => 'youtube',
'GoogleChirp' => 'youtube',
'YouTubeMusic' => 'youtube',
// Apple Podcasts (iOS app, Mac app, automated checks, HomePod, Apple TV).
'iTMS' => 'apple',
'AppleCoreMedia' => 'apple',
'Podcasts/' => 'apple',
'iTunes' => 'apple',
'AirPodcasts/' => 'apple',
// Spotify — substring catches `Spotify/…`, `spotify-rss-…`, all variants.
'Spotify' => 'spotify',
// Pocket Casts.
'Pocket Casts' => 'pocketcasts',
'PocketCasts' => 'pocketcasts',
// Amazon — `AmazonMusic` is the listening app; `Amazon Music Podcast`
// (with spaces) is the actual feed crawler. Keep both.
'AmazonMusic' => 'amazon',
'Amazon Music Podcast' => 'amazon',
// Podcast Index — substring `PodcastIndex` catches `Podcastindex.org/`,
// `PodcastIndexer/`, `PodcastIndexManager/`, `PodcastIndex Classifier/`.
'PodcastIndex' => 'podcastindex',
);
/**
* Inspect the current request's User-Agent and, if it's a directory
* crawler with an existing pending state, mark its state `'active'`.
* No-op if the UA is missing, not in the directory allowlist, or the
* matched directory has no pending state.
*/
public static function detect_and_record(): void {
$ua = isset( $_SERVER['HTTP_USER_AGENT'] )
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
: '';
if ( '' === $ua ) {
return;
}
$slug = null;
foreach ( self::NEEDLES as $needle => $candidate ) {
if ( false !== stripos( $ua, $needle ) ) {
$slug = $candidate;
break;
}
}
if ( null === $slug || ! isset( Settings::SHOW_URL_HOSTS[ $slug ] ) ) {
return;
}
$states = get_option( 'podcasting_show_states', array() );
if ( ! is_array( $states ) ) {
$states = array();
}
if ( ! isset( $states[ $slug ] ) || 'pending' !== $states[ $slug ] ) {
return;
}
// Concurrent first-fetches from different apps can race the read-modify-write.
// Benign: the loser's next poll (multi-hour cadence) re-writes its key. Worst
// case is one app sitting in `pending` for one extra poll cycle.
$states[ $slug ] = 'active';
update_option( 'podcasting_show_states', $states );
}
}