Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.59% |
70 / 71 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
| Episode_Media_Cache | |
98.59% |
70 / 71 |
|
66.67% |
2 / 3 |
23 | |
0.00% |
0 / 1 |
| prime | |
98.18% |
54 / 55 |
|
0.00% |
0 / 1 |
15 | |||
| attachment_id | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| attached_file_path | |
100.00% |
9 / 9 |
|
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 | |
| 8 | declare( strict_types = 1 ); |
| 9 | |
| 10 | namespace Automattic\Jetpack\Podcast\Feed; |
| 11 | |
| 12 | use 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 | */ |
| 20 | class 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 | } |