Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.59% covered (success)
98.59%
70 / 71
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Episode_Media_Cache
98.59% covered (success)
98.59%
70 / 71
66.67% covered (warning)
66.67%
2 / 3
23
0.00% covered (danger)
0.00%
0 / 1
 prime
98.18% covered (success)
98.18%
54 / 55
0.00% covered (danger)
0.00%
0 / 1
15
 attachment_id
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 attached_file_path
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Batch-resolves the media a podcast feed render needs, up front.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Podcast\Feed;
11
12use WP_Post;
13
14/**
15 * Left alone, every episode resolves its own media as it renders — enclosure
16 * URL to attachment ID, that attachment's metadata for `<itunes:duration>`, the
17 * featured image for `<itunes:image>` — roughly four uncached queries an item.
18 * {@see self::prime()} does the whole page in three.
19 */
20class Episode_Media_Cache {
21
22    /**
23     * `_wp_attached_file` path → attachment ID, from the last {@see self::prime()}.
24     *
25     * Keyed by path, not URL: filters ahead of ours rewrite the enclosure URL
26     * before {@see Customize_Feed::rewrite_enclosure()} reads it back out of the
27     * markup — WordPress.com forces it to `http` — and reducing both ends to the
28     * path folds that difference away.
29     *
30     * @var array<string, int>
31     */
32    private static $attachment_ids = array();
33
34    /**
35     * Paths per lookup. `posts_per_rss` is configurable and episodes carry more
36     * than one enclosure row, so the page size can't be trusted to bound the
37     * `IN` list.
38     */
39    private const CHUNK_SIZE = 500;
40
41    /**
42     * Resolve the page's enclosure URLs and warm the caches the item hooks read.
43     * Safe to call with the unfiltered post list.
44     *
45     * @param array $posts Posts about to be rendered. `the_posts` is a filter, so
46     *                     entries aren't guaranteed to be `WP_Post`.
47     */
48    public static function prime( array $posts ): void {
49        self::$attachment_ids = array();
50
51        $post_ids = array();
52        foreach ( $posts as $post ) {
53            if ( $post instanceof WP_Post ) {
54                $post_ids[] = (int) $post->ID;
55            }
56        }
57
58        if ( ! $post_ids ) {
59            return;
60        }
61
62        // `the_posts` runs before WP primes the loop's meta cache, so without this
63        // every read below — and core's `rss_enclosure()` later — is a round trip.
64        update_meta_cache( 'post', $post_ids );
65
66        $dir            = wp_get_upload_dir();
67        $paths          = array();
68        $attachment_ids = array();
69
70        foreach ( $post_ids as $post_id ) {
71            foreach ( (array) get_post_meta( $post_id, 'enclosure', false ) as $enclosure ) {
72                $url = esc_url( trim( explode( "\n", (string) $enclosure )[0] ) );
73                if ( '' !== $url ) {
74                    $paths[ self::attached_file_path( $url, $dir ) ] = true;
75                }
76            }
77
78            $thumbnail_id = (int) get_post_meta( $post_id, '_thumbnail_id', true );
79            if ( $thumbnail_id > 0 ) {
80                $attachment_ids[] = $thumbnail_id;
81            }
82        }
83
84        // Chunked, and never reached with no paths at all: `WP_Meta_Query` drops an
85        // `IN` clause whose value is an empty array, leaving a bare key match that
86        // would return every attachment on the site.
87        $found = array();
88        foreach ( array_chunk( array_keys( $paths ), self::CHUNK_SIZE ) as $chunk ) {
89            $found = array_merge(
90                $found,
91                get_posts(
92                    array(
93                        'post_type'              => 'attachment',
94                        // `attachment_url_to_postid()` scans `postmeta` unscoped, so any
95                        // status is a record it can pick and narrowing would hide the
96                        // ambiguity rather than settle it. `wp_insert_post()` only lets
97                        // an attachment hold four of these, but listing them here would
98                        // copy an invariant that lives in core.
99                        'post_status'            => array_keys( get_post_stati() ),
100                        'numberposts'            => -1,
101                        'fields'                 => 'ids',
102                        'orderby'                => 'none',
103                        'no_found_rows'          => true,
104                        'update_post_term_cache' => false,
105                        'meta_query'             => array(
106                            array(
107                                'key'     => '_wp_attached_file',
108                                'value'   => array_map( 'strval', $chunk ),
109                                'compare' => 'IN',
110                            ),
111                        ),
112                    )
113                )
114            );
115        }
116
117        $attachment_ids = array_values( array_unique( array_merge( $attachment_ids, $found ) ) );
118        if ( ! $attachment_ids ) {
119            return;
120        }
121
122        _prime_post_caches( $attachment_ids, false, true );
123
124        // One attachment can hold several `_wp_attached_file` rows and the lookup
125        // matches on any of them, so count it against every requested path it holds.
126        // Reading just the first value would bucket it under a path nobody asked
127        // for and leave the one that actually matched looking unique.
128        $candidates = array();
129        foreach ( $found as $id ) {
130            foreach ( (array) get_post_meta( $id, '_wp_attached_file', false ) as $value ) {
131                if ( isset( $paths[ (string) $value ] ) ) {
132                    $candidates[ (string) $value ][] = (int) $id;
133                }
134            }
135        }
136
137        // Unambiguous paths only. Core picks between duplicates in `postmeta` order,
138        // which a joined query can't reproduce, and a batch hit never reaches the
139        // fallback — so a wrong guess would be unrecoverable. Leave those to core.
140        foreach ( $candidates as $path => $ids ) {
141            $ids = array_unique( $ids );
142            if ( 1 === count( $ids ) ) {
143                self::$attachment_ids[ (string) $path ] = (int) reset( $ids );
144            }
145        }
146    }
147
148    /**
149     * The attachment behind an enclosure URL: from the batch when
150     * {@see self::prime()} resolved one unambiguously, otherwise from core.
151     *
152     * A hit runs the same filters, in the same order, that
153     * `attachment_url_to_postid()` would, so plugins overriding either end still
154     * win. A miss defers to core rather than answering 0 — offloaded-media plugins
155     * map a CDN URL back to its attachment through those same filters.
156     *
157     * @param string $url Enclosure URL, as it came back out of the markup.
158     * @return int Attachment ID, or 0.
159     */
160    public static function attachment_id( string $url ): int {
161        $id = self::$attachment_ids[ self::attached_file_path( $url, wp_get_upload_dir() ) ] ?? 0;
162
163        if ( 0 === $id ) {
164            return attachment_url_to_postid( $url );
165        }
166
167        /** This filter is documented in wp-includes/media.php */
168        $pre = apply_filters( 'pre_attachment_url_to_postid', null, $url );
169        if ( null !== $pre ) {
170            return (int) $pre;
171        }
172
173        /** This filter is documented in wp-includes/media.php */
174        return (int) apply_filters( 'attachment_url_to_postid', $id, $url );
175    }
176
177    /**
178     * Reduce an attachment URL to the relative path stored in `_wp_attached_file`,
179     * step for step with `attachment_url_to_postid()` — including its scheme
180     * normalization, which is what lets both ends of the batch agree. A URL from
181     * outside the uploads dir comes back unchanged and matches nothing.
182     *
183     * @param string $url URL to normalize.
184     * @param array  $dir `wp_get_upload_dir()` result.
185     * @return string
186     */
187    private static function attached_file_path( string $url, array $dir ): string {
188        $path      = $url;
189        $site_url  = wp_parse_url( $dir['url'] );
190        $url_parts = wp_parse_url( $path );
191
192        if ( isset( $url_parts['scheme'] ) && isset( $site_url['scheme'] ) && $url_parts['scheme'] !== $site_url['scheme'] ) {
193            $path = str_replace( $url_parts['scheme'], $site_url['scheme'], $path );
194        }
195
196        $baseurl = $dir['baseurl'] . '/';
197        if ( 0 === strpos( $path, $baseurl ) ) {
198            $path = substr( $path, strlen( $baseurl ) );
199        }
200
201        return $path;
202    }
203}