Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
Feed_Detection
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
10.01
0.00% covered (danger)
0.00%
0 / 1
 detect_and_record
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
10.01
1<?php
2/**
3 * Records when podcast directory crawlers fetch the feed.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Podcast\Feed;
11
12use Automattic\Jetpack\Podcast\Settings;
13
14/**
15 * Promotes an existing pending podcatcher `podcasting_show_states` entry to
16 * `'active'` the first time we see its UA fetch the feed. Idempotent
17 * thereafter.
18 */
19class Feed_Detection {
20
21    /**
22     * UA needle → podcatcher slug. First match wins, so order matters: more
23     * specific brand needles (e.g. `GooglePodcasts`, `YouTubeMusic`) come
24     * before generic ones (e.g. Apple's `Podcasts/`) that they share a
25     * substring with.
26     *
27     * Cross-referenced against opawg/user-agents-v2 (the OPAWG community
28     * dataset used by Podtrac, Megaphone, Art19, Chartable). When directories
29     * change UAs or new ones emerge, sync the relevant entries here:
30     *   https://github.com/opawg/user-agents-v2/blob/master/src/{apps,bots}.json
31     *
32     * @var array<string, string>
33     */
34    private const NEEDLES = array(
35        // YouTube / Google Podcasts — listed first so `GooglePodcasts/`
36        // doesn't get caught by Apple's broader `Podcasts/` needle below.
37        'Google-Podcast'       => 'youtube',
38        'YouTube-Podcast'      => 'youtube',
39        'GooglePodcasts'       => 'youtube',
40        'GoogleChirp'          => 'youtube',
41        'YouTubeMusic'         => 'youtube',
42
43        // Apple Podcasts (iOS app, Mac app, automated checks, HomePod, Apple TV).
44        'iTMS'                 => 'apple',
45        'AppleCoreMedia'       => 'apple',
46        'Podcasts/'            => 'apple',
47        'iTunes'               => 'apple',
48        'AirPodcasts/'         => 'apple',
49
50        // Spotify — substring catches `Spotify/…`, `spotify-rss-…`, all variants.
51        'Spotify'              => 'spotify',
52
53        // Pocket Casts.
54        'Pocket Casts'         => 'pocketcasts',
55        'PocketCasts'          => 'pocketcasts',
56
57        // Amazon — `AmazonMusic` is the listening app; `Amazon Music Podcast`
58        // (with spaces) is the actual feed crawler. Keep both.
59        'AmazonMusic'          => 'amazon',
60        'Amazon Music Podcast' => 'amazon',
61
62        // Podcast Index — substring `PodcastIndex` catches `Podcastindex.org/`,
63        // `PodcastIndexer/`, `PodcastIndexManager/`, `PodcastIndex Classifier/`.
64        'PodcastIndex'         => 'podcastindex',
65    );
66
67    /**
68     * Inspect the current request's User-Agent and, if it's a directory
69     * crawler with an existing pending state, mark its state `'active'`.
70     * No-op if the UA is missing, not in the directory allowlist, or the
71     * matched directory has no pending state.
72     */
73    public static function detect_and_record(): void {
74        $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
75            ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
76            : '';
77        if ( '' === $ua ) {
78            return;
79        }
80
81        $slug = null;
82        foreach ( self::NEEDLES as $needle => $candidate ) {
83            if ( false !== stripos( $ua, $needle ) ) {
84                $slug = $candidate;
85                break;
86            }
87        }
88
89        if ( null === $slug || ! isset( Settings::SHOW_URL_HOSTS[ $slug ] ) ) {
90            return;
91        }
92
93        $states = get_option( 'podcasting_show_states', array() );
94        if ( ! is_array( $states ) ) {
95            $states = array();
96        }
97
98        if ( ! isset( $states[ $slug ] ) || 'pending' !== $states[ $slug ] ) {
99            return;
100        }
101
102        // Concurrent first-fetches from different apps can race the read-modify-write.
103        // Benign: the loser's next poll (multi-hour cadence) re-writes its key. Worst
104        // case is one app sitting in `pending` for one extra poll cycle.
105        $states[ $slug ] = 'active';
106        update_option( 'podcasting_show_states', $states );
107    }
108}