From 61bd75b4c0cf9c09973cc9ce5ac52be50532339f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jul 2026 12:51:47 +0200 Subject: [PATCH 1/5] Federate Jetpack podcast episodes Jetpack's podcast-episode block stores the audio URL, mime type and cover art as block attributes, read via Episode_Block_Tags::get_block_attrs(). Hook the activitypub_attachments filter from the Jetpack integration to add the episode audio (and cover art) as an ActivityPub attachment, read directly from the block rather than relying on WordPress core's asynchronous enclosure meta. When the enclosure path already added the same audio, enrich it with the cover art instead of duplicating. Claude-Session: https://claude.ai/code/session_01Eam1mCnfuFmXKSAtYFxaua --- .../changelog/add-jetpack-podcast-attachment | 4 + integration/class-jetpack.php | 58 +++++++++++++ .../data/mocks/class-episode-block-tags.php | 35 ++++++++ .../tests/integration/class-test-jetpack.php | 81 +++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 .github/changelog/add-jetpack-podcast-attachment create mode 100644 tests/phpunit/data/mocks/class-episode-block-tags.php diff --git a/.github/changelog/add-jetpack-podcast-attachment b/.github/changelog/add-jetpack-podcast-attachment new file mode 100644 index 0000000000..ba854b164b --- /dev/null +++ b/.github/changelog/add-jetpack-podcast-attachment @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Federate Jetpack podcast episodes with their audio file and cover art. diff --git a/integration/class-jetpack.php b/integration/class-jetpack.php index 098dddc5fa..aec58f1df5 100644 --- a/integration/class-jetpack.php +++ b/integration/class-jetpack.php @@ -41,6 +41,10 @@ public static function init() { } \add_action( 'load-post-new.php', array( self::class, 'adapt_post_share' ) ); + + // A Jetpack episode is an ordinary post, so its audio is enriched onto the already-assembled + // attachments here, rather than through a dedicated transformer subclass like Podlove/SSP. + \add_filter( 'activitypub_attachments', array( self::class, 'add_podcast_attachment' ), 10, 2 ); } /** @@ -172,4 +176,58 @@ public static function adapt_post_share() { exit; } } + + /** + * Federate a Jetpack podcast episode's audio as an ActivityPub attachment. + * + * A Jetpack episode stores its audio in the `jetpack/podcast-episode` block, read back through + * the podcast package's own {@see \Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags}. Reading it + * there (rather than relying on WordPress core's asynchronous `enclosure` meta) makes the audio, + * its cover art, and its mime type available on every transform. When the core-enclosure path has + * already added the same audio, the existing attachment is enriched with the episode cover art + * instead of being duplicated. + * + * @param array $attachments The ActivityPub attachments. + * @param \WP_Post $post The post being transformed. + * + * @return array The attachments, with the podcast episode audio added or enriched. + */ + public static function add_podcast_attachment( $attachments, $post ) { + if ( ! \class_exists( '\Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags' ) ) { + return $attachments; + } + + $attrs = \Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags::get_block_attrs( $post ); + if ( empty( $attrs['mediaUrl'] ) ) { + return $attachments; + } + + $url = \esc_url_raw( $attrs['mediaUrl'] ); + $icon = empty( $attrs['coverArt']['url'] ) ? '' : \esc_url_raw( $attrs['coverArt']['url'] ); + + // The core-enclosure path may already have added this audio: enrich it with the cover art rather than duplicate it. + foreach ( $attachments as $index => $attachment ) { + if ( isset( $attachment['url'] ) && $attachment['url'] === $url ) { + if ( $icon && empty( $attachment['icon'] ) ) { + $attachments[ $index ]['icon'] = $icon; + } + return $attachments; + } + } + + $podcast = array( + 'type' => \ucfirst( $attrs['mediaType'] ?? 'Audio' ), + 'url' => $url, + 'mediaType' => \esc_attr( $attrs['mediaMimeType'] ?? '' ), + 'name' => \esc_attr( \get_the_title( $post ) ), + ); + + if ( $icon ) { + $podcast['icon'] = $icon; + } + + \array_unshift( $attachments, $podcast ); + + return $attachments; + } } diff --git a/tests/phpunit/data/mocks/class-episode-block-tags.php b/tests/phpunit/data/mocks/class-episode-block-tags.php new file mode 100644 index 0000000000..3021ee67e1 --- /dev/null +++ b/tests/phpunit/data/mocks/class-episode-block-tags.php @@ -0,0 +1,35 @@ +load_mock_episode_block_tags( + array( + 'mediaUrl' => 'https://example.com/episode.mp3', + 'mediaType' => 'audio', + 'mediaMimeType' => 'audio/mpeg', + 'coverArt' => array( 'url' => 'https://example.com/cover.jpg' ), + ) + ); + + $attachments = Jetpack::add_podcast_attachment( array(), \get_post( self::$post_id ) ); + + $this->assertCount( 1, $attachments ); + $this->assertSame( 'https://example.com/episode.mp3', $attachments[0]['url'] ); + $this->assertSame( 'Audio', $attachments[0]['type'] ); + $this->assertSame( 'audio/mpeg', $attachments[0]['mediaType'] ); + $this->assertSame( 'Test Post', $attachments[0]['name'] ); + $this->assertSame( 'https://example.com/cover.jpg', $attachments[0]['icon'] ); + } + + /** + * When the audio is already attached (via the core enclosure), only the cover art is added. + * + * @covers ::add_podcast_attachment + */ + public function test_add_podcast_attachment_enriches_existing() { + $this->load_mock_episode_block_tags( + array( + 'mediaUrl' => 'https://example.com/episode.mp3', + 'coverArt' => array( 'url' => 'https://example.com/cover.jpg' ), + ) + ); + + $existing = array( + array( + 'type' => 'Audio', + 'url' => 'https://example.com/episode.mp3', + ), + ); + + $attachments = Jetpack::add_podcast_attachment( $existing, \get_post( self::$post_id ) ); + + $this->assertCount( 1, $attachments, 'The audio must not be duplicated.' ); + $this->assertSame( 'https://example.com/cover.jpg', $attachments[0]['icon'] ); + } + + /** + * A post without a podcast episode is left unchanged. + * + * @covers ::add_podcast_attachment + */ + public function test_add_podcast_attachment_without_media_is_noop() { + $this->load_mock_episode_block_tags( array() ); + + $attachments = Jetpack::add_podcast_attachment( array( 'existing' ), \get_post( self::$post_id ) ); + + $this->assertSame( array( 'existing' ), $attachments ); + } } From 11fc319345bf974e90092902f3f4d9cfbdc194d0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jul 2026 13:00:27 +0200 Subject: [PATCH 2/5] Guard an empty sanitized podcast URL and remove the test filter - esc_url_raw() can drop an unsafe media URL to an empty string; return early so no attachment is added without a url. Adds a regression test. - Remove the activitypub_attachments filter in the Jetpack test tear_down so it cannot leak into later tests. Claude-Session: https://claude.ai/code/session_01Eam1mCnfuFmXKSAtYFxaua --- integration/class-jetpack.php | 7 ++++++- .../tests/integration/class-test-jetpack.php | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/integration/class-jetpack.php b/integration/class-jetpack.php index aec58f1df5..af1602aa83 100644 --- a/integration/class-jetpack.php +++ b/integration/class-jetpack.php @@ -202,7 +202,12 @@ public static function add_podcast_attachment( $attachments, $post ) { return $attachments; } - $url = \esc_url_raw( $attrs['mediaUrl'] ); + $url = \esc_url_raw( $attrs['mediaUrl'] ); + if ( empty( $url ) ) { + // Sanitizing dropped the URL (e.g. an unsafe scheme); an attachment without a url is invalid. + return $attachments; + } + $icon = empty( $attrs['coverArt']['url'] ) ? '' : \esc_url_raw( $attrs['coverArt']['url'] ); // The core-enclosure path may already have added this audio: enrich it with the cover art rather than duplicate it. diff --git a/tests/phpunit/tests/integration/class-test-jetpack.php b/tests/phpunit/tests/integration/class-test-jetpack.php index c66f045c7a..1d4383eaa9 100644 --- a/tests/phpunit/tests/integration/class-test-jetpack.php +++ b/tests/phpunit/tests/integration/class-test-jetpack.php @@ -87,6 +87,7 @@ public function tear_down() { \remove_filter( 'jetpack_api_include_comment_types_count', array( 'Activitypub\Integration\Jetpack', 'add_comment_types' ) ); \remove_filter( 'activitypub_following_row_actions', array( 'Activitypub\Integration\Jetpack', 'add_reader_link' ), 20 ); \remove_filter( 'pre_option_activitypub_following_ui', array( 'Activitypub\Integration\Jetpack', 'pre_option_activitypub_following_ui' ) ); + \remove_filter( 'activitypub_attachments', array( 'Activitypub\Integration\Jetpack', 'add_podcast_attachment' ), 10 ); // Clear the podcast mock so it cannot leak an attachment into other tests through the filter. if ( class_exists( '\Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags' ) ) { @@ -452,4 +453,17 @@ public function test_add_podcast_attachment_without_media_is_noop() { $this->assertSame( array( 'existing' ), $attachments ); } + + /** + * A media URL that sanitizes to empty (e.g. an unsafe scheme) adds no attachment. + * + * @covers ::add_podcast_attachment + */ + public function test_add_podcast_attachment_rejects_unsafe_url() { + $this->load_mock_episode_block_tags( array( 'mediaUrl' => 'javascript:alert(1)' ) ); + + $attachments = Jetpack::add_podcast_attachment( array(), \get_post( self::$post_id ) ); + + $this->assertSame( array(), $attachments ); + } } From a9ca5490bf800aab2285d018404e6929909212d7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 23 Jul 2026 13:15:04 +0200 Subject: [PATCH 3/5] Note Jetpack podcast federation in the integration README --- integration/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/README.md b/integration/README.md index 94e8b98f39..78274f6513 100644 --- a/integration/README.md +++ b/integration/README.md @@ -34,7 +34,7 @@ A few integrations are always initialized (`Nodeinfo`, `Webfinger`, `Surge`, `Li | Integration | What it does | |---|---| -| **Jetpack** | Syncs ActivityPub options and follower/following meta to WordPress.com, adds a Reader link on the Following screen, enables the Following UI, and adapts the "share to reply" flow. | +| **Jetpack** | Syncs ActivityPub options and follower/following meta to WordPress.com, adds a Reader link on the Following screen, enables the Following UI, adapts the "share to reply" flow, and federates Jetpack podcast episodes with their audio enclosure and cover art. | | **Enable Mastodon Apps** | Feeds ActivityPub account, follower, post, and notification data to [Enable Mastodon Apps](https://wordpress.org/plugins/enable-mastodon-apps/) so native Mastodon client apps work against the site. | | **BuddyPress** | Maps BuddyPress member profiles into ActivityPub actors and adapts the Followers/Following blocks. | From 5ae65a665533f5df22a90525acc649585c16fe45 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 24 Jul 2026 14:49:23 +0200 Subject: [PATCH 4/5] extract the max attachments helper --- includes/functions-post.php | 30 +++++++++++++++++++++++++++++ includes/transformer/class-post.php | 18 ++--------------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/includes/functions-post.php b/includes/functions-post.php index 7217336071..cd492c9838 100644 --- a/includes/functions-post.php +++ b/includes/functions-post.php @@ -254,6 +254,36 @@ function get_post_type_description( $post_type ) { return \apply_filters( 'activitypub_post_type_description', $description, $post_type->name, $post_type ); } +/** + * Get the maximum number of media attachments a post may federate. + * + * A per-post limit wins over the site-wide setting, and the filter has the final say. + * + * @since unreleased + * + * @param int $post_id The post ID. + * + * @return int The maximum number of media attachments. + */ +function get_max_attachments( $post_id ) { + $max_media = \get_post_meta( $post_id, 'activitypub_max_image_attachments', true ); + + if ( ! \is_numeric( $max_media ) ) { + $max_media = \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ); + } + + /** + * Filters the maximum number of media attachments allowed in a post. + * + * Despite the name suggesting only images, this filter controls the maximum number + * of all media attachments (images, audio, and video) that can be included in an + * ActivityPub post. The name is maintained for backwards compatibility. + * + * @param int $max_media Maximum number of media attachments. Default ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS. + */ + return (int) \apply_filters( 'activitypub_max_image_attachments', $max_media ); +} + /** * Get the enclosures of a post. * diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 6f7fd27551..e7d3688c9a 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -19,6 +19,7 @@ use function Activitypub\get_content_visibility; use function Activitypub\get_content_warning; use function Activitypub\get_enclosures; +use function Activitypub\get_max_attachments; use function Activitypub\get_rest_url_by_path; use function Activitypub\is_post_publicly_queryable; use function Activitypub\is_single_user; @@ -397,22 +398,7 @@ protected function get_attachment() { return $this->attachment; } - $max_media = \get_post_meta( $this->item->ID, 'activitypub_max_image_attachments', true ); - - if ( ! \is_numeric( $max_media ) ) { - $max_media = \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ); - } - - /** - * Filters the maximum number of media attachments allowed in a post. - * - * Despite the name suggesting only images, this filter controls the maximum number - * of all media attachments (images, audio, and video) that can be included in an - * ActivityPub post. The name is maintained for backwards compatibility. - * - * @param int $max_media Maximum number of media attachments. Default ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS. - */ - $max_media = (int) \apply_filters( 'activitypub_max_image_attachments', $max_media ); + $max_media = get_max_attachments( $this->item->ID ); if ( 0 === $max_media ) { $this->attachment = array(); From 6489f38ced79868fd21c5c52fbf9aa744e587f61 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 24 Jul 2026 14:49:34 +0200 Subject: [PATCH 5/5] federate episodes from both jetpack podcast features --- .../changelog/add-jetpack-podcast-attachment | 4 - .../changelog/add-podcast-episode-attachments | 4 + integration/README.md | 2 +- integration/class-jetpack.php | 240 ++++++++++++--- .../data/mocks/class-customize-feed.php | 34 +++ .../data/mocks/class-episode-block-tags.php | 42 +-- tests/phpunit/data/mocks/class-settings.php | 34 +++ .../tests/integration/class-test-jetpack.php | 287 +++++++++++++++--- 8 files changed, 541 insertions(+), 106 deletions(-) delete mode 100644 .github/changelog/add-jetpack-podcast-attachment create mode 100644 .github/changelog/add-podcast-episode-attachments create mode 100644 tests/phpunit/data/mocks/class-customize-feed.php create mode 100644 tests/phpunit/data/mocks/class-settings.php diff --git a/.github/changelog/add-jetpack-podcast-attachment b/.github/changelog/add-jetpack-podcast-attachment deleted file mode 100644 index ba854b164b..0000000000 --- a/.github/changelog/add-jetpack-podcast-attachment +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: added - -Federate Jetpack podcast episodes with their audio file and cover art. diff --git a/.github/changelog/add-podcast-episode-attachments b/.github/changelog/add-podcast-episode-attachments new file mode 100644 index 0000000000..93c2681deb --- /dev/null +++ b/.github/changelog/add-podcast-episode-attachments @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Federate podcast episodes published with Jetpack, including their audio file and cover art. diff --git a/integration/README.md b/integration/README.md index 78274f6513..8538ea3630 100644 --- a/integration/README.md +++ b/integration/README.md @@ -34,7 +34,7 @@ A few integrations are always initialized (`Nodeinfo`, `Webfinger`, `Surge`, `Li | Integration | What it does | |---|---| -| **Jetpack** | Syncs ActivityPub options and follower/following meta to WordPress.com, adds a Reader link on the Following screen, enables the Following UI, adapts the "share to reply" flow, and federates Jetpack podcast episodes with their audio enclosure and cover art. | +| **Jetpack** | Syncs ActivityPub options and follower/following meta to WordPress.com, adds a Reader link on the Following screen, enables the Following UI, adapts the "share to reply" flow, and federates podcast episodes from both Posts to Podcast and Jetpack Podcast with their audio and cover art. | | **Enable Mastodon Apps** | Feeds ActivityPub account, follower, post, and notification data to [Enable Mastodon Apps](https://wordpress.org/plugins/enable-mastodon-apps/) so native Mastodon client apps work against the site. | | **BuddyPress** | Maps BuddyPress member profiles into ActivityPub actors and adapts the Followers/Following blocks. | diff --git a/integration/class-jetpack.php b/integration/class-jetpack.php index af1602aa83..85cc83fefb 100644 --- a/integration/class-jetpack.php +++ b/integration/class-jetpack.php @@ -11,8 +11,14 @@ use Activitypub\Collection\Following; use Activitypub\Http; use Automattic\Jetpack\Connection\Manager; +use Automattic\Jetpack\Podcast\Feed\Customize_Feed; +use Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags; +use Automattic\Jetpack\Podcast\Settings as Podcast_Settings; +use function Activitypub\get_enclosures; +use function Activitypub\get_max_attachments; use function Activitypub\is_activity_object; +use function Activitypub\normalize_url; /** * Jetpack integration class. @@ -42,9 +48,10 @@ public static function init() { \add_action( 'load-post-new.php', array( self::class, 'adapt_post_share' ) ); - // A Jetpack episode is an ordinary post, so its audio is enriched onto the already-assembled - // attachments here, rather than through a dedicated transformer subclass like Podlove/SSP. - \add_filter( 'activitypub_attachments', array( self::class, 'add_podcast_attachment' ), 10, 2 ); + // Enriched onto the already-assembled attachments rather than through a transformer subclass + // like Podlove/SSP: a subclass is winner-take-all, so a site running one of those alongside a + // Jetpack podcast would get one behaviour instead of both. + \add_filter( 'activitypub_attachments', array( self::class, 'add_podcast_attachments' ), 10, 2 ); } /** @@ -177,62 +184,221 @@ public static function adapt_post_share() { } } + /** - * Federate a Jetpack podcast episode's audio as an ActivityPub attachment. + * Federate a podcast episode's audio as an ActivityPub attachment. + * + * Jetpack has two podcast surfaces, developed separately, and an episode can come from either: + * + * - Posts to Podcast writes a `jetpack/podcast-episode` block carrying the audio it produced. + * - Jetpack Podcast treats every post in the configured podcast category as an episode, whose + * audio is an ordinary WordPress enclosure. * - * A Jetpack episode stores its audio in the `jetpack/podcast-episode` block, read back through - * the podcast package's own {@see \Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags}. Reading it - * there (rather than relying on WordPress core's asynchronous `enclosure` meta) makes the audio, - * its cover art, and its mime type available on every transform. When the core-enclosure path has - * already added the same audio, the existing attachment is enriched with the episode cover art - * instead of being duplicated. + * Neither survives the transformer on its own. Episode audio is usually hosted off-site, so it + * has no attachment ID, and {@see \Activitypub\Transformer\Base::filter_unique_attachments()} + * drops every media entry without one, meaning that audio never reaches this filter. It is + * resolved here instead, and either added or, when the media library already contributed it, + * given the artwork the podcast feed shows for the same episode. * * @param array $attachments The ActivityPub attachments. * @param \WP_Post $post The post being transformed. * - * @return array The attachments, with the podcast episode audio added or enriched. + * @return array The attachments, with the episode audio added or enriched. */ - public static function add_podcast_attachment( $attachments, $post ) { - if ( ! \class_exists( '\Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags' ) ) { + public static function add_podcast_attachments( $attachments, $post ) { + $is_show_episode = self::is_show_episode( $post ); + $episode = self::get_episode_audio( $post, $is_show_episode ); + + if ( ! $episode ) { return $attachments; } - $attrs = \Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags::get_block_attrs( $post ); - if ( empty( $attrs['mediaUrl'] ) ) { + $icon = self::get_cover_art( $post, $episode['coverArt'] ?? '', $is_show_episode ); + $index = self::find_attachment_by_url( $attachments, $episode['url'] ); + + if ( null !== $index ) { + /* + * The audio is already attached, so only the artwork is missing. It is replaced rather + * than filled in: the transformer falls back to the site icon for any audio without a + * poster, and the show's own cover is the better answer for an episode. + */ + if ( $icon ) { + $attachments[ $index ]['icon'] = $icon; + } + return $attachments; } - $url = \esc_url_raw( $attrs['mediaUrl'] ); - if ( empty( $url ) ) { - // Sanitizing dropped the URL (e.g. an unsafe scheme); an attachment without a url is invalid. - return $attachments; + $audio = array( + 'type' => $episode['type'], + 'url' => $episode['url'], + 'name' => \esc_attr( \get_the_title( $post ) ), + ); + + // An episode attached by URL carries no mime type, and omitting the property beats sending + // an empty one, which stops a receiver classifying the attachment at all. + if ( $episode['mediaType'] ) { + $audio['mediaType'] = $episode['mediaType']; } - $icon = empty( $attrs['coverArt']['url'] ) ? '' : \esc_url_raw( $attrs['coverArt']['url'] ); + if ( $icon ) { + $audio['icon'] = $icon; + } - // The core-enclosure path may already have added this audio: enrich it with the cover art rather than duplicate it. - foreach ( $attachments as $index => $attachment ) { - if ( isset( $attachment['url'] ) && $attachment['url'] === $url ) { - if ( $icon && empty( $attachment['icon'] ) ) { - $attachments[ $index ]['icon'] = $icon; - } - return $attachments; + \array_unshift( $attachments, $audio ); + + // The transformer trimmed to the configured maximum before this filter ran, so prepending + // the audio would otherwise put the post one attachment over a limit the site chose. An + // episode that arrived alone cannot exceed it, and the maximum is never 0 here because the + // transformer returns before this filter in that case. + if ( \count( $attachments ) > 1 ) { + $attachments = \array_slice( $attachments, 0, get_max_attachments( $post->ID ) ); + } + + return $attachments; + } + + /** + * Resolve the audio of a podcast episode. + * + * A Posts to Podcast episode keeps its generated audio in the `jetpack/podcast-episode` block. + * A Jetpack Podcast episode instead carries an ordinary enclosure, which only counts as an + * episode when the post is filed in the configured podcast category, since any post may have an + * enclosure without being part of the show. + * + * @param \WP_Post $post The post being transformed. + * @param bool $is_show_episode Whether the post is filed in the configured podcast category. + * + * @return array|null The episode `type`, `url`, `mediaType` and `coverArt`, or null when the post is not an episode. + */ + private static function get_episode_audio( $post, $is_show_episode ) { + $attrs = self::get_episode_block_attrs( $post ); + + if ( ! empty( $attrs['mediaUrl'] ) ) { + // Sanitizing drops an unsafe scheme, and an attachment without a URL is invalid. + $url = \esc_url_raw( $attrs['mediaUrl'] ); + + if ( $url ) { + return array( + 'type' => \ucfirst( $attrs['mediaType'] ?? 'audio' ), + 'url' => $url, + 'mediaType' => \esc_attr( $attrs['mediaMimeType'] ?? '' ), + 'coverArt' => empty( $attrs['coverArt']['url'] ) ? '' : \esc_url_raw( $attrs['coverArt']['url'] ), + ); } } - $podcast = array( - 'type' => \ucfirst( $attrs['mediaType'] ?? 'Audio' ), - 'url' => $url, - 'mediaType' => \esc_attr( $attrs['mediaMimeType'] ?? '' ), - 'name' => \esc_attr( \get_the_title( $post ) ), - ); + if ( ! $is_show_episode ) { + return null; + } - if ( $icon ) { - $podcast['icon'] = $icon; + foreach ( get_enclosures( $post->ID ) as $enclosure ) { + $mime_type = $enclosure['mediaType'] ?? ''; + + if ( ! \str_starts_with( $mime_type, 'audio/' ) ) { + continue; + } + + $url = \esc_url_raw( $enclosure['url'] ); + + if ( $url ) { + return array( + 'type' => 'Audio', + 'url' => $url, + 'mediaType' => \esc_attr( $mime_type ), + ); + } } - \array_unshift( $attachments, $podcast ); + return null; + } - return $attachments; + /** + * Resolve the cover art for a podcast episode. + * + * The episode's own artwork wins, then the post's featured image, then the show image. That is + * the order the podcast feed covers an item with, so a federated episode carries the artwork + * subscribers already see. The show image applies only to an episode of the show itself, so a + * generated episode on an unrelated post does not advertise the podcast's cover. + * + * @param \WP_Post $post The post being transformed. + * @param string $cover_art The episode's own artwork, when it has any. + * @param bool $is_show_episode Whether the post is filed in the configured podcast category. + * + * @return string The cover art URL, or an empty string when none is set. + */ + private static function get_cover_art( $post, $cover_art, $is_show_episode ) { + if ( $cover_art ) { + return $cover_art; + } + + $thumbnail = \get_the_post_thumbnail_url( $post, 'full' ); + + if ( $thumbnail ) { + return \esc_url_raw( $thumbnail ); + } + + if ( ! $is_show_episode || ! \method_exists( Podcast_Settings::class, 'raw_show_image_url' ) ) { + return ''; + } + + return \esc_url_raw( (string) Podcast_Settings::raw_show_image_url() ); + } + + /** + * Test whether a post is an episode of the site's own podcast. + * + * @param \WP_Post $post The post being transformed. + * + * @return bool Whether the post is in the configured podcast category. + */ + private static function is_show_episode( $post ) { + if ( ! \method_exists( Customize_Feed::class, 'resolve_category_id' ) ) { + return false; + } + + // The category can be stored as an ID or as an archive slug, and only Jetpack knows which applies. + $category_id = (int) Customize_Feed::resolve_category_id(); + + return $category_id && \in_category( $category_id, $post ); + } + + /** + * Read the attributes of the post's `jetpack/podcast-episode` block. + * + * @param \WP_Post $post The post being transformed. + * + * @return array The block attributes, empty when the post has no episode block. + */ + private static function get_episode_block_attrs( $post ) { + if ( ! \method_exists( Episode_Block_Tags::class, 'get_block_attrs' ) ) { + return array(); + } + + return (array) Episode_Block_Tags::get_block_attrs( $post ); + } + + /** + * Find the attachment carrying a given media URL. + * + * Matched on host and path so the same file is still recognised when the stored enclosure and + * the episode block disagree on the scheme, which is the case on every site that moved to HTTPS + * after publishing. + * + * @param array $attachments The ActivityPub attachments. + * @param string $url The media URL to look for. + * + * @return int|string|null The attachment key, or null when the media is not in the list. + */ + private static function find_attachment_by_url( $attachments, $url ) { + $needle = normalize_url( $url ); + + foreach ( $attachments as $index => $attachment ) { + if ( isset( $attachment['url'] ) && normalize_url( $attachment['url'] ) === $needle ) { + return $index; + } + } + + return null; } } diff --git a/tests/phpunit/data/mocks/class-customize-feed.php b/tests/phpunit/data/mocks/class-customize-feed.php new file mode 100644 index 0000000000..9c8d552d02 --- /dev/null +++ b/tests/phpunit/data/mocks/class-customize-feed.php @@ -0,0 +1,34 @@ +category->create( array( 'name' => 'Podcast' ) ); + + \Automattic\Jetpack\Podcast\Feed\Customize_Feed::$category_id = $category_id; + \Automattic\Jetpack\Podcast\Settings::$show_image_url = self::SHOW_IMAGE_URL; + + if ( $in_category ) { + \wp_set_post_categories( self::$post_id, array( $category_id ), true ); + } + } + /** * Clean up after tests. */ @@ -87,11 +123,13 @@ public function tear_down() { \remove_filter( 'jetpack_api_include_comment_types_count', array( 'Activitypub\Integration\Jetpack', 'add_comment_types' ) ); \remove_filter( 'activitypub_following_row_actions', array( 'Activitypub\Integration\Jetpack', 'add_reader_link' ), 20 ); \remove_filter( 'pre_option_activitypub_following_ui', array( 'Activitypub\Integration\Jetpack', 'pre_option_activitypub_following_ui' ) ); - \remove_filter( 'activitypub_attachments', array( 'Activitypub\Integration\Jetpack', 'add_podcast_attachment' ), 10 ); + \remove_filter( 'activitypub_attachments', array( 'Activitypub\Integration\Jetpack', 'add_podcast_attachments' ), 10 ); - // Clear the podcast mock so it cannot leak an attachment into other tests through the filter. + // Clear the podcast mocks so they cannot leak an attachment into other tests through the filter. if ( class_exists( '\Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags' ) ) { - \Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags::$attrs = array(); + \Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags::$attrs = array(); + \Automattic\Jetpack\Podcast\Feed\Customize_Feed::$category_id = 0; + \Automattic\Jetpack\Podcast\Settings::$show_image_url = ''; } parent::tear_down(); @@ -391,11 +429,55 @@ public function test_add_reader_link( $item, $feed_id, $expected_url, $should_ha } /** - * A Jetpack podcast episode adds its audio and cover art as an attachment. + * Transform a post and return the attachments the transformer produced. + * + * Driving the real transformer is the point: the filter runs on an already-assembled, + * already-capped list, and hand-built fixtures hide whether the code matches what actually + * arrives there. * - * @covers ::add_podcast_attachment + * @param int $post_id The post to transform. + * + * @return array The attachments. */ - public function test_add_podcast_attachment_adds_audio() { + private function transform_attachments( $post_id ) { + \clean_post_cache( $post_id ); + + $transformer = \Activitypub\Transformer\Factory::get_transformer( \get_post( $post_id ) ); + + return $transformer->to_object()->get_attachment(); + } + + /** + * Attach an enclosure to a post, the way WordPress records one. + * + * @param int $post_id The post ID. + * @param string $url The media URL. + */ + private function add_enclosure( $post_id, $url ) { + \add_post_meta( $post_id, 'enclosure', $url . "\n1234\naudio/mpeg\n" ); + } + + /** + * The filter is registered so the transformer passes it the post. + * + * Without the second argument the callback receives null and cannot resolve an episode at all, + * which no assertion on the callback itself would catch. + * + * @covers ::init + */ + public function test_init_registers_the_attachment_filter() { + Jetpack::init(); + + $this->assertSame( 10, \has_filter( 'activitypub_attachments', array( Jetpack::class, 'add_podcast_attachments' ) ) ); + } + + /** + * A Posts to Podcast episode federates the audio from its block. + * + * @covers ::add_podcast_attachments + */ + public function test_episode_block_audio_is_federated() { + Jetpack::init(); $this->load_mock_episode_block_tags( array( 'mediaUrl' => 'https://example.com/episode.mp3', @@ -405,65 +487,182 @@ public function test_add_podcast_attachment_adds_audio() { ) ); - $attachments = Jetpack::add_podcast_attachment( array(), \get_post( self::$post_id ) ); + $attachments = $this->transform_attachments( self::$post_id ); - $this->assertCount( 1, $attachments ); $this->assertSame( 'https://example.com/episode.mp3', $attachments[0]['url'] ); $this->assertSame( 'Audio', $attachments[0]['type'] ); $this->assertSame( 'audio/mpeg', $attachments[0]['mediaType'] ); - $this->assertSame( 'Test Post', $attachments[0]['name'] ); $this->assertSame( 'https://example.com/cover.jpg', $attachments[0]['icon'] ); } /** - * When the audio is already attached (via the core enclosure), only the cover art is added. + * An episode with no mime type omits the property rather than sending an empty one. * - * @covers ::add_podcast_attachment + * @covers ::add_podcast_attachments */ - public function test_add_podcast_attachment_enriches_existing() { - $this->load_mock_episode_block_tags( - array( - 'mediaUrl' => 'https://example.com/episode.mp3', - 'coverArt' => array( 'url' => 'https://example.com/cover.jpg' ), - ) - ); + public function test_episode_without_mime_type_omits_media_type() { + Jetpack::init(); + $this->load_mock_episode_block_tags( array( 'mediaUrl' => 'https://example.com/episode.mp3' ) ); - $existing = array( - array( - 'type' => 'Audio', - 'url' => 'https://example.com/episode.mp3', - ), - ); + $attachments = $this->transform_attachments( self::$post_id ); - $attachments = Jetpack::add_podcast_attachment( $existing, \get_post( self::$post_id ) ); + $this->assertArrayNotHasKey( 'mediaType', $attachments[0] ); + } - $this->assertCount( 1, $attachments, 'The audio must not be duplicated.' ); - $this->assertSame( 'https://example.com/cover.jpg', $attachments[0]['icon'] ); + /** + * A media URL that sanitizes to empty (an unsafe scheme) adds no attachment. + * + * @covers ::add_podcast_attachments + */ + public function test_unsafe_media_url_is_rejected() { + Jetpack::init(); + $this->load_mock_episode_block_tags( array( 'mediaUrl' => 'javascript:alert(1)' ) ); + + $this->assertSame( array(), $this->transform_attachments( self::$post_id ) ); } /** - * A post without a podcast episode is left unchanged. + * The same audio is federated once even when the enclosure and the block disagree on the scheme. * - * @covers ::add_podcast_attachment + * @covers ::add_podcast_attachments */ - public function test_add_podcast_attachment_without_media_is_noop() { - $this->load_mock_episode_block_tags( array() ); + public function test_audio_is_not_duplicated_across_schemes() { + Jetpack::init(); - $attachments = Jetpack::add_podcast_attachment( array( 'existing' ), \get_post( self::$post_id ) ); + /* + * The audio has to be in the media library, otherwise the transformer drops it before the + * filter and there is nothing to deduplicate against. The block then points at the same + * file over https, as it does on every site that moved to https after publishing. + */ + $audio_id = self::factory()->attachment->create_upload_object( AP_TESTS_DIR . '/data/assets/sample-audio.mp3' ); + $enclosure_url = \wp_get_attachment_url( $audio_id ); - $this->assertSame( array( 'existing' ), $attachments ); + $this->add_enclosure( self::$post_id, $enclosure_url ); + $this->load_mock_episode_block_tags( array( 'mediaUrl' => \str_replace( 'http://', 'https://', $enclosure_url ) ) ); + + $attachments = $this->transform_attachments( self::$post_id ); + + $this->assertCount( 1, $attachments, 'The same audio must not be attached twice.' ); + + \wp_delete_attachment( $audio_id, true ); } /** - * A media URL that sanitizes to empty (e.g. an unsafe scheme) adds no attachment. + * Adding the episode audio does not push the post over its attachment limit. * - * @covers ::add_podcast_attachment + * @covers ::add_podcast_attachments */ - public function test_add_podcast_attachment_rejects_unsafe_url() { - $this->load_mock_episode_block_tags( array( 'mediaUrl' => 'javascript:alert(1)' ) ); + public function test_episode_audio_respects_the_attachment_limit() { + Jetpack::init(); + $this->load_mock_episode_block_tags( array( 'mediaUrl' => 'https://example.com/episode.mp3' ) ); + + $thumbnail_id = self::factory()->attachment->create_upload_object( AP_TESTS_DIR . '/data/assets/test.jpg' ); + \set_post_thumbnail( self::$post_id, $thumbnail_id ); + \update_post_meta( self::$post_id, 'activitypub_max_image_attachments', 1 ); + + $attachments = $this->transform_attachments( self::$post_id ); + + $this->assertCount( 1, $attachments ); + $this->assertSame( 'https://example.com/episode.mp3', $attachments[0]['url'] ); + + \delete_post_meta( self::$post_id, 'activitypub_max_image_attachments' ); + \delete_post_thumbnail( self::$post_id ); + \wp_delete_attachment( $thumbnail_id, true ); + } + + /** + * A Jetpack Podcast episode hosted off-site federates its audio. + * + * An external enclosure has no attachment ID, so the transformer drops it before the filter + * runs; the integration has to add it back or the episode federates with no audio at all. + * + * @covers ::add_podcast_attachments + */ + public function test_external_enclosure_episode_is_federated() { + Jetpack::init(); + $this->load_mock_podcast_show(); + $episode_url = \home_url( '/podcast/episode.mp3' ); + $this->add_enclosure( self::$post_id, $episode_url ); + + $attachments = $this->transform_attachments( self::$post_id ); + + $this->assertCount( 1, $attachments ); + $this->assertSame( $episode_url, $attachments[0]['url'] ); + $this->assertSame( 'Audio', $attachments[0]['type'] ); + $this->assertSame( 'audio/mpeg', $attachments[0]['mediaType'] ); + $this->assertSame( self::SHOW_IMAGE_URL, $attachments[0]['icon'] ); + } + + /** + * An episode whose audio lives in the media library gets the show artwork. + * + * The transformer stamps its own icon on every audio attachment, so the show image only lands + * if the integration replaces it. + * + * @covers ::add_podcast_attachments + */ + public function test_media_library_episode_gets_the_show_cover_art() { + Jetpack::init(); + $this->load_mock_podcast_show(); + + $audio_id = self::factory()->attachment->create_upload_object( AP_TESTS_DIR . '/data/assets/sample-audio.mp3' ); + $this->add_enclosure( self::$post_id, \wp_get_attachment_url( $audio_id ) ); - $attachments = Jetpack::add_podcast_attachment( array(), \get_post( self::$post_id ) ); + // The transformer covers any audio without a poster with the site icon, so there is already + // an icon on the attachment by the time the integration sees it. + $icon_id = self::factory()->attachment->create_upload_object( AP_TESTS_DIR . '/data/assets/test.jpg' ); + \update_option( 'site_icon', $icon_id ); + + $attachments = $this->transform_attachments( self::$post_id ); + + $this->assertCount( 1, $attachments ); + $this->assertSame( self::SHOW_IMAGE_URL, $attachments[0]['icon'], 'The show artwork must win over the site icon.' ); - $this->assertSame( array(), $attachments ); + \delete_option( 'site_icon' ); + \wp_delete_attachment( $icon_id, true ); + \wp_delete_attachment( $audio_id, true ); + } + + /** + * An enclosure on a post outside the podcast category is not treated as an episode. + * + * @covers ::add_podcast_attachments + */ + public function test_enclosure_outside_the_podcast_category_is_not_an_episode() { + Jetpack::init(); + $this->load_mock_podcast_show( false ); + $this->add_enclosure( self::$post_id, \home_url( '/podcast/episode.mp3' ) ); + + $this->assertSame( array(), $this->transform_attachments( self::$post_id ), 'A stray enclosure must not federate as an episode.' ); + } + + /** + * The show artwork is applied to the episode audio only. + * + * @covers ::add_podcast_attachments + */ + public function test_other_audio_does_not_get_the_show_cover_art() { + Jetpack::init(); + $this->load_mock_podcast_show(); + $this->add_enclosure( self::$post_id, \home_url( '/podcast/episode.mp3' ) ); + + $other = static function ( $attachments ) { + $attachments[] = array( + 'type' => 'Audio', + 'url' => 'https://example.com/voicemail.mp3', + 'mediaType' => 'audio/mpeg', + ); + + return $attachments; + }; + + // Runs before the integration, so the episode resolution sees it in the list. + \add_filter( 'activitypub_attachments', $other, 5 ); + $attachments = $this->transform_attachments( self::$post_id ); + \remove_filter( 'activitypub_attachments', $other, 5 ); + + $this->assertCount( 2, $attachments ); + $this->assertSame( 'https://example.com/voicemail.mp3', $attachments[1]['url'] ); + $this->assertArrayNotHasKey( 'icon', $attachments[1], 'Unrelated audio must not advertise the show artwork.' ); } }