Skip to main content

TierAwareFeedNamespaceInterface

Opt-in extension for feed namespace handlers whose channel-level output depends on which episodes the requesting tier can actually see. Extends FeedNamespaceInterface with one extra method.

Most channel tags are pure show metadata and need nothing but the show ID, which is why channel_tags() takes only that. A few describe episodes from the channel — <podcast:trailer> is the first — and those cannot be built safely without knowing the tier.

The reason this exists is a safety rule, not an API convenience. A channel-level tag that names an episode hands that episode’s enclosure URL to every recipient of the feed, whatever tier they are on. Emitting one for an episode the availability rules are still withholding routes straight around the gate the plugin exists to enforce. Two obligations follow, and both are on the implementer:

  1. Restrict output to $available_episode_ids. Never run a fresh query for episodes — the ID list is the gate, not a hint.
  2. Treat an empty list as “advertise nothing”, never “no restriction”. This is the failure mode that looks like working code: a handler that skips its filter when the list is empty advertises everything to the tier entitled to least.

Existing handlers are unaffected and need no change. FeedCompiler calls the richer method when a handler implements this interface and falls back to channel_tags() otherwise, so third-party handlers registered through benecaster_feed_namespaces keep working untouched and never have to know it exists.

Add a channel-level feed tag that names an episode, safely

Free Advanced

Most channel-level feed tags are made of show metadata — a licence, a GUID, a funding link — and need nothing but the show ID to build. A channel tag that names an episode is a different problem, and getting it wrong hands out media people paid for.

The <channel> block is compiled once per tier and sent to every subscriber on that tier. Put an episode’s enclosure URL there and you have published it to all of them at once — including, if you built the list from your own query, episodes the availability rules are still withholding. That routes around the gate the whole plugin exists to enforce, and it does it in the one part of the feed every recipient reads.

Implement TierAwareFeedNamespaceInterface instead of FeedNamespaceInterface. Benecaster then hands your handler the tier slug plus every episode ID that tier may receive — the whole feed, not just the page being compiled, so a trailer published years ago still appears on page 1.

Two rules the interface cannot enforce for you. Never widen the search beyond $available_episode_ids, and treat an empty array as “advertise nothing” rather than “no restriction”. The second is the one that looks like working code: a handler that skips its filter when the list is empty advertises everything to the tier entitled to least. Core’s own <podcast:trailer> follows both rules.

Pass the media URL through the enclosure filters, as core does. A site running the download proxy has those rewriting every <enclosure>; a channel tag that skips them republishes the raw host URL the proxy exists to hide.

Handlers implementing only FeedNamespaceInterface keep working untouched — Benecaster falls back to channel_tags() for them, so nothing you have already shipped needs changing.

<?php
use Benecaster\Episode\EpisodeMeta;
use Benecaster\Feed\Namespaces\TierAwareFeedNamespaceInterface;

class MyHighlightsNamespace implements TierAwareFeedNamespaceInterface {

    public function get_xmlns(): array {
        return [ 'myns' => 'https://example.com/ns/1.0' ];
    }

    // No tier context here, so advertise no episodes at all rather than guess.
    public function channel_tags( int $show_id ): array {
        return $this->channel_tags_for_tier( $show_id, '', [] );
    }

    public function channel_tags_for_tier( int $show_id, string $tier_slug, array $available_episode_ids ): array {
        // Empty means "this tier can reach nothing", NOT "no filter applied".
        if ( empty( $available_episode_ids ) ) {
            return [];
        }

        $frags = [];

        foreach ( $available_episode_ids as $episode_id ) {
            $episode_id = (int) $episode_id;

            if ( ! get_post_meta( $episode_id, '_my_highlight', true ) ) {
                continue;
            }

            $ep_meta = new EpisodeMeta( $episode_id );

            // Pass the URL through the same filters the <enclosure> uses, or a
            // site running the download proxy gets its raw host URL republished
            // in the channel — the one place every recipient sees it.
            $url = (string) apply_filters(
                'benecaster_feed_enclosure_url',
                $ep_meta->get_audio_url(),
                $episode_id,
                $show_id,
                $tier_slug
            );

            if ( '' === $url ) {
                continue;
            }

            $frags[] = '<myns:highlight url="' . esc_url( $url ) . '"/>' . "\n";
        }

        return $frags;
    }

    public function episode_tags( int $episode_id, int $show_id ): array {
        return [];
    }
}

add_filter( 'benecaster_feed_namespaces', function ( array $handlers ): array {
    $handlers[] = new MyHighlightsNamespace();
    return $handlers;
} );

View on GitHub →