Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.23% covered (warning)
69.23%
135 / 195
57.14% covered (warning)
57.14%
12 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Customize_Feed
69.23% covered (warning)
69.23%
135 / 195
57.14% covered (warning)
57.14%
12 / 21
277.87
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_register_feed_hooks
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 output_namespaces
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 feed_title
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 feed_description
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 output_channel_tags
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 output_item_tags
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
10.12
 skip_block_in_feed
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 capture_item_summary
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rewrite_enclosure
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 reset_render_state
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 configure_feed_query
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 filter_posts_with_enclosure
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 is_podcast_feed_query
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 explicit_string
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 show_image_url
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 build_stats_url
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 resolve_category_id
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 episode_image_url
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 maybe_photon
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 category_tag
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Adds podcast tags + tracked enclosure URLs to the RSS feed for the
4 * configured podcast category.
5 *
6 * @package automattic/jetpack-podcast
7 */
8
9declare( strict_types = 1 );
10
11namespace Automattic\Jetpack\Podcast\Feed;
12
13use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14use Automattic\Jetpack\Podcast\Episode_Query;
15use Automattic\Jetpack\Podcast\Settings;
16use WP_Post;
17
18/**
19 * Hooks into RSS2 rendering when the current request is the podcast category
20 * feed, adding `<itunes:*>` + `<podcast:*>` tags at channel and item level
21 * and rewriting `<enclosure>` URLs through the WPCOM stats endpoint.
22 */
23class Customize_Feed {
24
25    /**
26     * Whether `init()` has wired its hooks.
27     *
28     * @var bool
29     */
30    private static $registered = false;
31
32    /**
33     * Enclosure URLs already emitted in the current feed render, keyed by post
34     * ID then URL. Reset on `rss2_head` so each feed starts clean — see
35     * {@see self::reset_render_state()}.
36     *
37     * @var array<int, array<string, true>>
38     */
39    private static $seen_enclosures = array();
40
41    /**
42     * The `<description>` most recently rendered, as `[ post ID, value ]`, for
43     * {@see self::output_item_tags()} to reuse. One slot: the template emits
44     * `<description>` immediately before `rss2_item` fires for the same item.
45     * Reset per render — see {@see self::reset_render_state()} — so a template
46     * that skips `<description>` can't match a previous render's post ID.
47     *
48     * @var array{0: int, 1: string}
49     */
50    private static $item_summary = array( 0, '' );
51
52    /**
53     * Wire the late-binding `wp` action that decides whether to register the
54     * feed-modification hooks for this request. Idempotent.
55     */
56    public static function init() {
57        if ( self::$registered ) {
58            return;
59        }
60        self::$registered = true;
61
62        add_action( 'wp', array( __CLASS__, 'maybe_register_feed_hooks' ) );
63
64        // These hooks fire during query execution — before the `wp` action — so
65        // they're registered up-front and self-gated to the podcast feed query,
66        // rather than wired conditionally in `maybe_register_feed_hooks`.
67        //
68        // `pre_get_posts` runs last so the gate reads the category every other
69        // callback has finished rewriting — and so `get_queried_object()` doesn't
70        // memoize a term ahead of them.
71        add_action( 'pre_get_posts', array( __CLASS__, 'configure_feed_query' ), PHP_INT_MAX );
72        add_filter( 'the_posts', array( __CLASS__, 'filter_posts_with_enclosure' ), 10, 2 );
73    }
74
75    /**
76     * Register the RSS2 hooks if this request is the configured podcast feed.
77     * Also fires `Feed_Detection` while we're here — same gating, no need to
78     * walk the post query twice.
79     */
80    public static function maybe_register_feed_hooks() {
81        if ( ! is_feed() ) {
82            return;
83        }
84        $category_id = self::resolve_category_id();
85        if ( 0 === $category_id || ! is_category( $category_id ) ) {
86            return;
87        }
88
89        // Strip channel-level tags that conflict with the iTunes-compliant
90        // header: blavatar / site-icon `<image>` duplicates `<itunes:image>`,
91        // and `<cloud …/>` from rsscloud isn't part of the podcast spec.
92        remove_action( 'rss2_head', 'rss2_blavatar' );
93        remove_action( 'rss2_head', 'rss2_site_icon' );
94        remove_action( 'rss2_head', 'rsscloud_add_rss_cloud_element' );
95
96        add_action( 'rss2_ns', array( __CLASS__, 'output_namespaces' ) );
97        add_filter( 'wp_title_rss', array( __CLASS__, 'feed_title' ) );
98        add_filter( 'bloginfo_rss', array( __CLASS__, 'feed_description' ), 10, 2 );
99        add_action( 'rss2_head', array( __CLASS__, 'reset_render_state' ), 0 );
100        add_action( 'rss2_head', array( __CLASS__, 'output_channel_tags' ) );
101        add_action( 'rss2_item', array( __CLASS__, 'output_item_tags' ) );
102        add_filter( 'rss_enclosure', array( __CLASS__, 'rewrite_enclosure' ) );
103        // Last, so the captured value is what `<description>` actually printed —
104        // a filter above priority 10 would otherwise leave us caching an
105        // intermediate string and `<itunes:summary>` disagreeing with it.
106        add_filter( 'the_excerpt_rss', array( __CLASS__, 'capture_item_summary' ), PHP_INT_MAX );
107
108        add_filter( 'option_rss_use_excerpt', '__return_false' );
109        // Request-scoped to the feed: only the queried episodes render here, so
110        // this never touches block output outside the podcast feed response.
111        add_filter( 'pre_render_block', array( __CLASS__, 'skip_block_in_feed' ), 10, 2 );
112        add_filter( 'comments_open', '__return_false' );
113        add_filter( 'get_comments_number', '__return_zero' );
114        add_filter( 'the_category_rss', '__return_empty_string' );
115        remove_action( 'rss2_item', 'mrss_item', 10 );
116        remove_action( 'rss2_item', 'mrss_news_item' );
117
118        Feed_Detection::detect_and_record();
119    }
120
121    /**
122     * Add iTunes and Podcasting 2.0 XML namespaces to the `<rss>` open tag.
123     */
124    public static function output_namespaces() {
125        echo "\n\t" . 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"' . "\n";
126        echo "\t" . 'xmlns:podcast="https://podcastindex.org/namespace/1.0"' . "\n";
127    }
128
129    /**
130     * Override the feed title with `podcasting_title`, falling back to
131     * `Blog Name » Category Name`.
132     *
133     * @param string $title Existing title.
134     * @return string
135     */
136    public static function feed_title( $title ) {
137        $override = (string) get_option( 'podcasting_title', '' );
138        if ( '' !== $override ) {
139            return esc_xml( $override );
140        }
141
142        $category = get_category( self::resolve_category_id() );
143        if ( $category && ! is_wp_error( $category ) ) {
144            return esc_xml( get_bloginfo( 'name' ) ) . ' &#187; ' . esc_xml( $category->name );
145        }
146        return esc_xml( $title );
147    }
148
149    /**
150     * Replace the `bloginfo_rss('description')` value with `podcasting_summary`.
151     *
152     * `bloginfo_rss()` echoes the filter return value directly, so we strip and
153     * escape here — matches the channel-level `<itunes:summary>` treatment and
154     * keeps stray markup in the option from leaking into `<description>`.
155     *
156     * @param string $value Existing value.
157     * @param string $field Field being requested.
158     * @return string
159     */
160    public static function feed_description( $value, $field ) {
161        if ( 'description' !== $field ) {
162            return $value;
163        }
164        return esc_xml( wp_strip_all_tags( (string) get_option( 'podcasting_summary', '' ) ) );
165    }
166
167    /**
168     * Channel-level podcast tags (rss2_head).
169     */
170    public static function output_channel_tags() {
171        $summary = (string) get_option( 'podcasting_summary', '' );
172        if ( '' !== $summary ) {
173            echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $summary ) ) . "</itunes:summary>\n";
174        }
175
176        $author = (string) get_option( 'podcasting_talent_name', '' );
177        if ( '' !== $author ) {
178            echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
179        }
180
181        $email = wp_strip_all_tags( (string) get_option( 'podcasting_email', '' ) );
182        if ( '' !== $email ) {
183            echo '<itunes:owner><itunes:email>' . esc_xml( $email ) . "</itunes:email></itunes:owner>\n";
184        }
185
186        $copyright = (string) get_option( 'podcasting_copyright', '' );
187        if ( '' !== $copyright ) {
188            echo '<copyright>' . esc_xml( wp_strip_all_tags( $copyright ) ) . "</copyright>\n";
189        }
190
191        /**
192         * Explicit content flag
193         */
194        echo '<itunes:explicit>' . esc_html( self::explicit_string() ) . "</itunes:explicit>\n";
195
196        $image = self::show_image_url();
197        if ( '' !== $image ) {
198            echo '<itunes:image href="' . esc_url( $image ) . '" />' . "\n";
199        }
200
201        echo self::category_tag( (string) get_option( 'podcasting_category_1', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
202        echo self::category_tag( (string) get_option( 'podcasting_category_2', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
203        echo self::category_tag( (string) get_option( 'podcasting_category_3', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
204    }
205
206    /**
207     * Item-level podcast tags (rss2_item).
208     */
209    public static function output_item_tags() {
210        global $post;
211
212        if ( ! $post instanceof WP_Post ) {
213            return;
214        }
215
216        $author = get_the_author();
217        if ( '' === $author ) {
218            $author = (string) get_option( 'podcasting_talent_name', '' );
219        }
220        if ( '' !== $author ) {
221            echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
222        }
223
224        // Reuse the string `<description>` just emitted for this item; rebuilding
225        // it costs a second `wp_trim_excerpt()` pass over the whole post body.
226        // The fallback covers a feed template that skips `<description>`.
227        $excerpt = self::$item_summary[0] === (int) $post->ID
228            ? self::$item_summary[1]
229            : (string) apply_filters( 'the_excerpt_rss', get_the_excerpt() );
230        if ( '' !== $excerpt ) {
231            echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $excerpt ) ) . "</itunes:summary>\n";
232        }
233
234        // Per-item cover art: prefer the block's `coverArt`, fall back to the
235        // post's featured image. Either way, photon-resize to 3000×3000 to
236        // honour Apple's square-cover requirement. When neither is present
237        // the channel-level `<itunes:image>` applies as default per spec.
238        $attrs      = Episode_Block_Tags::get_block_attrs( $post );
239        $cover_url  = isset( $attrs['coverArt']['url'] ) ? trim( (string) $attrs['coverArt']['url'] ) : '';
240        $item_image = '' !== $cover_url ? self::maybe_photon( $cover_url ) : self::episode_image_url( $post->ID );
241        if ( '' !== $item_image ) {
242            echo '<itunes:image href="' . esc_url( $item_image ) . '" />' . "\n";
243        }
244
245        // Block-driven iTunes + Podcasting 2.0 tags. Legacy audio posts
246        // without the block contribute nothing — they keep their pre-block
247        // behavior intact aside from the cover art handled above.
248        if ( ! empty( $attrs ) ) {
249            Episode_Block_Tags::render_from_attrs( $attrs );
250        }
251    }
252
253    /**
254     * Keep the episode, feed-player, and subscribe blocks out of
255     * `<content:encoded>`, leaving the surrounding show-note prose.
256     *
257     * Short-circuits on `pre_render_block` rather than blanking the output on
258     * `render_block`, which runs the callback first and throws the result away.
259     * That callback is expensive per item: the episode block resolves DNS via
260     * `wp_http_validate_url()`, the player block fetches a feed over HTTP.
261     *
262     * @param string|null $pre_render   Short-circuit value; `null` renders normally.
263     * @param array       $parsed_block Parsed block, including its `blockName`.
264     * @return string|null
265     */
266    public static function skip_block_in_feed( $pre_render, $parsed_block ) {
267        if ( isset( $parsed_block['blockName'] ) && in_array(
268            $parsed_block['blockName'],
269            array( 'jetpack/podcast-episode', 'jetpack/podcast-player', 'jetpack/subscriptions' ),
270            true
271        ) ) {
272            return '';
273        }
274        return $pre_render;
275    }
276
277    /**
278     * Stash the item's rendered `<description>` for `<itunes:summary>` to reuse.
279     * Pass-through — the value is never modified.
280     *
281     * @param string $excerpt Filtered item excerpt.
282     * @return string
283     */
284    public static function capture_item_summary( $excerpt ) {
285        self::$item_summary = array( (int) get_the_ID(), (string) $excerpt );
286        return $excerpt;
287    }
288
289    /**
290     * Rewrite the enclosure URL through the WPCOM stats endpoint and append
291     * `<itunes:duration>` when resolvable. Duration is looked up against the
292     * *original* attachment URL — the stats URL is synthetic.
293     *
294     * A podcast item is only valid with a single `<enclosure>`, but core
295     * `rss_enclosure()` emits one per `enclosure` post-meta row and posts
296     * routinely accumulate several (URL drift across re-uploads / CDN hosts
297     * that `do_enclose()`'s dedup treats as distinct). Rewriting keys on
298     * post ID, so those rows all collapse to the same stats URL — we track
299     * emitted URLs per item and drop repeats so the feed carries exactly one.
300     *
301     * @param string $enclosure Generated enclosure markup.
302     * @return string
303     */
304    public static function rewrite_enclosure( $enclosure ) {
305        global $post;
306
307        if ( ! preg_match( '/url="([^"]*)"/i', $enclosure, $match ) ) {
308            return $enclosure;
309        }
310
311        $original_url = $match[1];
312        $final_url    = $original_url;
313        $post_obj     = $post instanceof WP_Post ? $post : null;
314
315        /**
316         * Whether to rewrite the enclosure through the WPCOM stats endpoint.
317         * Token-gated feeds (notably WPCOM's `private-podcasts.php`) opt out
318         * — the stats URL is a deterministic public endpoint that would
319         * bypass any token gating on the feed itself.
320         *
321         * @param bool         $enable Default true.
322         * @param WP_Post|null $post   The post being rendered.
323         */
324        $enable = (bool) apply_filters( 'wpcom_podcasting_enable_play_tracking', true, $post_obj );
325
326        // Skip rewrite for externally hosted enclosures — the stats endpoint 404s anything that isn't a local attachment.
327        $attachment_id = Episode_Media_Cache::attachment_id( $original_url );
328
329        if ( null !== $post_obj && $enable && $attachment_id > 0 ) {
330            // `null` when the site isn't connected; passed through so the filter can still inject a value.
331            $default_blog_id = Connection_Manager::get_site_id( true );
332
333            /**
334             * Override the blog ID baked into the stats URL.
335             *
336             * @param int|null $blog_id Default Jetpack connection site ID, or null when unavailable.
337             * @param WP_Post  $post    The post being rendered.
338             */
339            $blog_id = (int) apply_filters( 'wpcom_podcasting_tracked_blog_id', $default_blog_id, $post_obj );
340
341            // Bail when we can't resolve a real blog ID — emit the original URL rather than a guaranteed-404 stats URL.
342            if ( $blog_id > 0 ) {
343                $final_url = esc_url( self::build_stats_url( $blog_id, (int) $post_obj->ID, $original_url ) );
344                $enclosure = preg_replace_callback(
345                    '/url="[^"]*"/i',
346                    /**
347                     * Replace the matched `url="…"` attribute with the stats URL.
348                     * `$matches` is required by `preg_replace_callback`'s callable
349                     * signature but ignored — we always emit the same value.
350                     *
351                     * @param array $matches Regex matches.
352                     * @return string
353                     */
354                    static function ( array $matches ) use ( $final_url ) {
355                        unset( $matches );
356                        return 'url="' . $final_url . '"';
357                    },
358                    $enclosure,
359                    1
360                );
361            }
362        }
363
364        // Drop repeats: rows keyed per post, by final URL, so distinct enclosures
365        // survive while the duplicate stats URLs collapse to one. Registry is
366        // cleared per render — see `reset_render_state()`.
367        $post_id = null !== $post_obj ? (int) $post_obj->ID : 0;
368        if ( isset( self::$seen_enclosures[ $post_id ][ $final_url ] ) ) {
369            return '';
370        }
371        self::$seen_enclosures[ $post_id ][ $final_url ] = true;
372
373        if ( 0 === $attachment_id ) {
374            return $enclosure;
375        }
376
377        $metadata = wp_get_attachment_metadata( $attachment_id );
378        $duration = is_array( $metadata ) ? absint( $metadata['length'] ?? 0 ) : 0;
379
380        return 0 === $duration
381            ? $enclosure
382            : $enclosure . '<itunes:duration>' . $duration . "</itunes:duration>\n";
383    }
384
385    /**
386     * Clear the per-render statics. Hooked on `rss2_head` (at priority 0, before
387     * any item renders) so re-generating a feed within a single long-lived
388     * process — WP-CLI, a warm worker — starts fresh instead of dropping every
389     * enclosure as already-seen or reusing the last render's summary.
390     */
391    public static function reset_render_state() {
392        self::$seen_enclosures = array();
393        self::$item_summary    = array( 0, '' );
394    }
395
396    /**
397     * Cap the podcast feed at the configured number of episodes and keep it to
398     * posts with an enclosure, so `LIMIT`/`OFFSET` paginate over real episodes.
399     * Core reads the `posts_per_rss` query var before the site option of the
400     * same name, so every other feed keeps the site's length.
401     *
402     * @param \WP_Query $query Query about to run.
403     */
404    public static function configure_feed_query( $query ) {
405        if ( ! self::is_podcast_feed_query( $query ) ) {
406            return;
407        }
408
409        $query->set( 'posts_per_rss', Settings::feed_limit() );
410        $query->set( Episode_Query::QUERY_VAR, true );
411    }
412
413    /**
414     * A podcast item without an enclosure is invalid per Apple's spec and can
415     * take down the whole submission. The `enclosure` post meta is what
416     * `rss_enclosure()` reads, so it's the authoritative signal here too.
417     *
418     * {@see Episode_Query} already excludes these at query time; this stays as
419     * a cheap final guard.
420     *
421     * Doubles as the warm-up point for the render: this is the first hook that
422     * sees the whole page of episodes, so {@see Episode_Media_Cache::prime()}
423     * resolves their media here rather than leaving it to per-item lookups.
424     *
425     * @param WP_Post[] $posts Posts about to be looped over.
426     * @param \WP_Query $query Query that produced them.
427     * @return WP_Post[]
428     */
429    public static function filter_posts_with_enclosure( $posts, $query ) {
430        if ( ! self::is_podcast_feed_query( $query ) ) {
431            return $posts;
432        }
433
434        Episode_Media_Cache::prime( $posts );
435
436        return array_values(
437            array_filter(
438                $posts,
439                static function ( $post ) {
440                    return $post instanceof WP_Post
441                        && ! empty( get_post_meta( $post->ID, 'enclosure', false ) );
442                }
443            )
444        );
445    }
446
447    /**
448     * Whether `$query` is the main podcast category feed query — the shared gate
449     * for the query-time hooks ({@see self::configure_feed_query()} and
450     * {@see self::filter_posts_with_enclosure()}), which fire before the `wp`
451     * action and so can't lean on `maybe_register_feed_hooks()`.
452     *
453     * @param \WP_Query $query Query to inspect.
454     * @return bool
455     */
456    private static function is_podcast_feed_query( $query ): bool {
457        if ( ! $query->is_main_query() || ! $query->is_feed() || ! $query->is_category() ) {
458            return false;
459        }
460        $category_id = self::resolve_category_id();
461        if ( 0 === $category_id ) {
462            return false;
463        }
464        $queried = $query->get_queried_object();
465        return $queried && isset( $queried->term_id ) && (int) $queried->term_id === $category_id;
466    }
467
468    /**
469     * Stored explicit value, normalized to the `'true'`/`'false'` strings the
470     * iTunes spec requires. Reuses `Settings::sanitize_explicit`
471     * so legacy `'yes'`/`'no'`/`'clean'` and modern boolean storage both work.
472     *
473     * @return string
474     */
475    public static function explicit_string(): string {
476        return Settings::sanitize_explicit( get_option( 'podcasting_explicit', false ) ) ? 'true' : 'false';
477    }
478
479    /**
480     * Show-level cover image URL — `Settings::raw_show_image_url()` routed
481     * through Photon at 3000×3000 when available.
482     *
483     * @return string
484     */
485    private static function show_image_url(): string {
486        $url = Settings::raw_show_image_url();
487        return '' === $url ? '' : self::maybe_photon( $url );
488    }
489
490    /**
491     * Build the WPCOM stats URL for a given episode. The endpoint redirects
492     * to the audio file after recording the play — the package never serves
493     * it, only points at it. Audio extensions outside the recognized set
494     * fall back to `mp3` to keep the URL shape uniform (matches the Podtrac
495     * / Megaphone / Art19 convention).
496     *
497     * @param int    $blog_id      WPCOM blog ID (Atomic should override via the
498     *                             `wpcom_podcasting_tracked_blog_id` filter).
499     * @param int    $post_id      Episode post ID.
500     * @param string $original_url Original enclosure URL — extension is pulled from here.
501     * @return string
502     */
503    private static function build_stats_url( int $blog_id, int $post_id, string $original_url ): string {
504        $path = (string) wp_parse_url( $original_url, PHP_URL_PATH );
505        $ext  = (string) preg_replace( '/[^a-z0-9]/', '', strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ) );
506        if ( ! in_array( $ext, array( 'mp3', 'm4a', 'm4b', 'aac', 'ogg', 'oga', 'opus', 'wav', 'flac', 'mp4', 'm4v', 'mov' ), true ) ) {
507            $ext = 'mp3';
508        }
509        return sprintf(
510            'https://public-api.wordpress.com/wpcom/v2/sites/%d/podcast-play/%d.%s',
511            $blog_id,
512            $post_id,
513            $ext
514        );
515    }
516
517    /**
518     * Resolve the configured podcast category ID. Prefers the numeric
519     * `podcasting_category_id`, falling back to a slug lookup against the
520     * legacy `podcasting_archive` option — older sites pre-date numeric
521     * storage and only have the slug. Returns 0 when neither resolves.
522     *
523     * A numeric ID whose term was deleted means "not configured" — the slug
524     * is not consulted in that case.
525     *
526     * @return int
527     */
528    public static function resolve_category_id(): int {
529        $category_id = (int) get_option( 'podcasting_category_id', 0 );
530        if ( $category_id > 0 ) {
531            $category = get_category( $category_id );
532            return ( $category && ! is_wp_error( $category ) && isset( $category->term_id ) ) ? (int) $category->term_id : 0;
533        }
534
535        $slug = (string) get_option( 'podcasting_archive', '' );
536        if ( '' === $slug ) {
537            return 0;
538        }
539
540        $term = get_term_by( 'slug', $slug, 'category' );
541        return ( $term && ! is_wp_error( $term ) && isset( $term->term_id ) ) ? (int) $term->term_id : 0;
542    }
543
544    /**
545     * Episode-level image URL — the post's featured image, Photon-resized,
546     * or `''` when no featured image is set. Used as the fallback per-item
547     * cover when the block doesn't supply its own.
548     *
549     * @param int $post_id Episode post ID.
550     * @return string
551     */
552    private static function episode_image_url( int $post_id ): string {
553        if ( ! has_post_thumbnail( $post_id ) ) {
554            return '';
555        }
556        $src = wp_get_attachment_image_src( get_post_thumbnail_id( $post_id ), 'full' );
557        if ( ! is_array( $src ) || empty( $src[0] ) ) {
558            return '';
559        }
560        return self::maybe_photon( $src[0] );
561    }
562
563    /**
564     * Route through Photon at exactly 3000×3000 so the feed always serves a
565     * square cover, regardless of the source aspect ratio. `resize` center-crops
566     * (unlike `fit`, which only constrains within the box); Apple's spec wants
567     * 1400–3000 px square art and rejects non-square covers.
568     *
569     * @param string $url Image URL.
570     * @return string
571     */
572    public static function maybe_photon( string $url ): string {
573        if ( ! function_exists( 'jetpack_photon_url' ) ) {
574            return $url;
575        }
576        // @phan-suppress-next-line PhanUndeclaredFunction -- Provided by Jetpack's Photon module at runtime; guarded by `function_exists` above.
577        return (string) jetpack_photon_url( $url, array( 'resize' => '3000,3000' ), 'https' );
578    }
579
580    /**
581     * Build a single `<itunes:category>` tag from a stored option value. The
582     * stored format is one of:
583     *   - `''` (no category)
584     *   - `'Foo'` → single category
585     *   - `'Foo,Bar'` → category Foo with subcategory Bar
586     *
587     * Includes a back-compat translation pass for a few legacy values that were
588     * stored in non-canonical shapes before validation tightened.
589     *
590     * @param string $stored Raw option value.
591     * @return string Empty string if no category, otherwise an XML fragment.
592     */
593    public static function category_tag( string $stored ): string {
594        static $legacy_aliases = array(
595            'Education,Education'                => 'Education',
596            'Education,Education Technology'     => 'Education,Educational Technology',
597            'Tech News'                          => 'Technology,Tech News',
598            'Sports &amp; Recreation,Technology' => 'Technology',
599            'Sports &amp; Recreation,Gadgets'    => 'Technology,Gadgets',
600            'Sports,Football'                    => 'Sports,American Football',
601            'Sports,Soccer'                      => 'Sports,Football (Soccer)',
602        );
603        $category              = $legacy_aliases[ $stored ] ?? $stored;
604
605        if ( '' === $category ) {
606            return '';
607        }
608
609        // `ent2ncr()` normalises named HTML entities (e.g. `&nbsp;`, `&copy;`) into
610        // numeric character references so an attribute value containing them stays
611        // well-formed XML after esc_attr().
612        $splits = explode( ',', $category );
613        if ( 2 === count( $splits ) ) {
614            return '<itunes:category text="' . ent2ncr( esc_attr( $splits[0] ) ) . '">' . "\n"
615                . "\t" . '<itunes:category text="' . ent2ncr( esc_attr( $splits[1] ) ) . '" />' . "\n"
616                . "</itunes:category>\n";
617        }
618        return '<itunes:category text="' . ent2ncr( esc_attr( $category ) ) . '" />' . "\n";
619    }
620}