rss_helper.php 16.8 KB
Newer Older
1
<?php
2

3
4
declare(strict_types=1);

5
6
7
8
9
10
/**
 * @copyright  2020 Podlibre
 * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
 * @link       https://castopod.org/
 */

11
12
use App\Entities\Category;
use App\Entities\Podcast;
13
use App\Libraries\SimpleRSSElement;
14
use CodeIgniter\I18n\Time;
15
use Config\Mimes;
16

17
if (! function_exists('get_rss_feed')) {
18
19
20
    /**
     * Generates the rss feed for a given podcast entity
     *
21
     * @param string $serviceSlug The name of the service that fetches the RSS feed for future reference when the audio file is eventually downloaded
22
23
     * @return string rss feed as xml
     */
24
    function get_rss_feed(Podcast $podcast, string $serviceSlug = ''): string
25
26
27
    {
        $episodes = $podcast->episodes;

28
        $itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
29

30
        $podcastNamespace =
31
32
33
            'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md';

        $rss = new SimpleRSSElement(
34
            "<?xml version='1.0' encoding='utf-8'?><rss version='2.0' xmlns:itunes='{$itunesNamespace}' xmlns:podcast='{$podcastNamespace}' xmlns:content='http://purl.org/rss/1.0/modules/content/'></rss>",
35
36
        );

37
        $channel = $rss->addChild('channel');
38

39
        $atomLink = $channel->addChild('atom:link', null, 'http://www.w3.org/2005/Atom');
40
41
42
        $atomLink->addAttribute('href', $podcast->feed_url);
        $atomLink->addAttribute('rel', 'self');
        $atomLink->addAttribute('type', 'application/rss+xml');
43

44
        if ($podcast->new_feed_url !== null) {
45
            $channel->addChild('new-feed-url', $podcast->new_feed_url, $itunesNamespace);
46
47
        }

48
        // the last build date corresponds to the creation of the feed.xml cache
49
50
        $channel->addChild('lastBuildDate', (new Time('now'))->format(DATE_RFC1123));
        $channel->addChild('generator', 'Castopod Host - https://castopod.org/');
51
        $channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html');
52

53
        $channel->addChild('guid', $podcast->guid, $podcastNamespace);
54
55
        $channel->addChild('title', $podcast->title);
        $channel->addChildWithCDATA('description', $podcast->description_html);
56

57
        $itunesImage = $channel->addChild('image', null, $itunesNamespace);
58

59
        // FIXME: This should be downsized to 1400x1400
60
        $itunesImage->addAttribute('href', $podcast->image->url);
61

62
        $channel->addChild('language', $podcast->language_code);
63
        if ($podcast->location !== null) {
64
            $locationElement = $channel->addChild(
65
                'location',
66
                htmlspecialchars($podcast->location->name),
67
                $podcastNamespace,
68
            );
69
70
            if ($podcast->location->geo !== null) {
                $locationElement->addAttribute('geo', $podcast->location->geo);
71
            }
72
73
            if ($podcast->location->osm !== null) {
                $locationElement->addAttribute('osm', $podcast->location->osm);
74
75
            }
        }
76
        if ($podcast->payment_pointer !== null) {
77
            $valueElement = $channel->addChild('value', null, $podcastNamespace);
78
79
80
            $valueElement->addAttribute('type', 'webmonetization');
            $valueElement->addAttribute('method', '');
            $valueElement->addAttribute('suggested', '');
81
            $recipientElement = $valueElement->addChild('valueRecipient', null, $podcastNamespace);
82
83
            $recipientElement->addAttribute('name', $podcast->owner_name);
            $recipientElement->addAttribute('type', 'ILP');
84
            $recipientElement->addAttribute('address', $podcast->payment_pointer);
85
            $recipientElement->addAttribute('split', '100');
86
87
        }
        $channel
88
            ->addChild('locked', $podcast->is_locked ? 'yes' : 'no', $podcastNamespace)
89
            ->addAttribute('owner', $podcast->owner_email);
90
        if ($podcast->imported_feed_url !== null) {
91
            $channel->addChild('previousUrl', $podcast->imported_feed_url, $podcastNamespace);
92
93
        }

94
        foreach ($podcast->podcasting_platforms as $podcastingPlatform) {
95
96
            $podcastingPlatformElement = $channel->addChild('id', null, $podcastNamespace);
            $podcastingPlatformElement->addAttribute('platform', $podcastingPlatform->slug);
97
            if ($podcastingPlatform->link_content !== null) {
98
                $podcastingPlatformElement->addAttribute('id', $podcastingPlatform->link_content);
99
            }
100
            if ($podcastingPlatform->link_url !== null) {
101
                $podcastingPlatformElement->addAttribute('url', htmlspecialchars($podcastingPlatform->link_url));
102
103
104
            }
        }

105
        foreach ($podcast->social_platforms as $socialPlatform) {
106
107
108
            $socialPlatformElement = $channel->addChild(
                'social',
                $socialPlatform->link_content,
109
                $podcastNamespace,
110
            );
111
            $socialPlatformElement->addAttribute('platform', $socialPlatform->slug);
112
            if ($socialPlatform->link_url !== null) {
113
                $socialPlatformElement->addAttribute('url', htmlspecialchars($socialPlatform->link_url));
114
            }
115
116
        }

117
        foreach ($podcast->funding_platforms as $fundingPlatform) {
118
119
120
            $fundingPlatformElement = $channel->addChild(
                'funding',
                $fundingPlatform->link_content,
121
                $podcastNamespace,
122
            );
123
            $fundingPlatformElement->addAttribute('platform', $fundingPlatform->slug);
124
            if ($fundingPlatform->link_url !== null) {
125
                $fundingPlatformElement->addAttribute('url', htmlspecialchars($fundingPlatform->link_url));
126
            }
127
128
        }

129
130
131
132
133
134
135
136
137
        foreach ($podcast->persons as $person) {
            foreach ($person->roles as $role) {
                $personElement = $channel->addChild(
                    'person',
                    htmlspecialchars($person->full_name),
                    $podcastNamespace,
                );

                $personElement->addAttribute('img', $person->image->large_url);
138

139
                if ($person->information_url !== null) {
140
                    $personElement->addAttribute('href', $person->information_url);
141
142
143
                }

                $personElement->addAttribute(
144
145
                    'role',
                    htmlspecialchars(
146
                        lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en'),
147
                    ),
148
                );
149

150
                $personElement->addAttribute(
151
                    'group',
152
                    htmlspecialchars(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')),
153
154
155
156
                );
            }
        }

157
158
159
160
161
162
163
164
165
        // set main category first, then other categories as apple
        add_category_tag($channel, $podcast->category);
        foreach ($podcast->other_categories as $other_category) {
            add_category_tag($channel, $other_category);
        }

        $channel->addChild(
            'explicit',
            $podcast->parental_advisory === 'explicit' ? 'true' : 'false',
166
            $itunesNamespace,
167
168
169
170
171
        );

        $channel->addChild(
            'author',
            $podcast->publisher ? $podcast->publisher : $podcast->owner_name,
172
            $itunesNamespace,
173
174
175
        );
        $channel->addChild('link', $podcast->link);

176
        $owner = $channel->addChild('owner', null, $itunesNamespace);
177

178
        $owner->addChild('name', $podcast->owner_name, $itunesNamespace);
179

180
        $owner->addChild('email', $podcast->owner_email, $itunesNamespace);
181

182
        $channel->addChild('type', $podcast->type, $itunesNamespace);
183
184
185
        $podcast->copyright &&
            $channel->addChild('copyright', $podcast->copyright);
        $podcast->is_blocked &&
186
            $channel->addChild('block', 'Yes', $itunesNamespace);
187
        $podcast->is_completed &&
188
            $channel->addChild('complete', 'Yes', $itunesNamespace);
189
190
191
192
193

        $image = $channel->addChild('image');
        $image->addChild('url', $podcast->image->feed_url);
        $image->addChild('title', $podcast->title);
        $image->addChild('link', $podcast->link);
194

195
        if ($podcast->custom_rss !== null) {
196
197
            array_to_rss([
                'elements' => $podcast->custom_rss,
198
            ], $channel);
199
        }
200

201
202
203
204
205
206
207
208
        foreach ($episodes as $episode) {
            $item = $channel->addChild('item');
            $item->addChild('title', $episode->title);
            $enclosure = $item->addChild('enclosure');

            $enclosure->addAttribute(
                'url',
                $episode->audio_file_analytics_url .
209
                    ($serviceSlug === ''
210
211
212
                        ? ''
                        : '?_from=' . urlencode($serviceSlug)),
            );
213
            $enclosure->addAttribute('length', (string) $episode->audio_file_size);
214
215
216
            $enclosure->addAttribute('type', $episode->audio_file_mimetype);

            $item->addChild('guid', $episode->guid);
217
            $item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123));
218
            if ($episode->location !== null) {
219
220
                $locationElement = $item->addChild(
                    'location',
221
                    htmlspecialchars($episode->location->name),
222
                    $podcastNamespace,
223
                );
224
                if ($episode->location->geo !== null) {
225
                    $locationElement->addAttribute('geo', $episode->location->geo);
226
                }
227
                if ($episode->location->osm !== null) {
228
                    $locationElement->addAttribute('osm', $episode->location->osm);
229
230
                }
            }
231
            $item->addChildWithCDATA('description', $episode->getDescriptionHtml($serviceSlug));
232
            $item->addChild('duration', (string) $episode->audio_file_duration, $itunesNamespace);
233
            $item->addChild('link', $episode->link);
234
235
            $episodeItunesImage = $item->addChild('image', null, $itunesNamespace);
            $episodeItunesImage->addAttribute('href', $episode->image->feed_url);
236
237
238
239
240
241
242

            $episode->parental_advisory &&
                $item->addChild(
                    'explicit',
                    $episode->parental_advisory === 'explicit'
                        ? 'true'
                        : 'false',
243
                    $itunesNamespace,
244
245
246
                );

            $episode->number &&
247
                $item->addChild('episode', (string) $episode->number, $itunesNamespace);
248
            $episode->season_number &&
249
                $item->addChild('season', (string) $episode->season_number, $itunesNamespace);
250
            $item->addChild('episodeType', $episode->type, $itunesNamespace);
251

252
253
254
255
256
            // add link to episode comments as podcast-activity format
            $comments = $item->addChild('comments', null, $podcastNamespace);
            $comments->addAttribute('uri', url_to('episode-comments', $podcast->name, $episode->slug));
            $comments->addAttribute('contentType', 'application/podcast-activity+json');

257
            if ($episode->transcript_file_url) {
258
259
                $transcriptElement = $item->addChild('transcript', null, $podcastNamespace);
                $transcriptElement->addAttribute('url', $episode->transcript_file_url);
260
261
                $transcriptElement->addAttribute(
                    'type',
262
263
264
                    Mimes::guessTypeFromExtension(
                        pathinfo($episode->transcript_file_url, PATHINFO_EXTENSION)
                    ) ?? 'text/html',
265
                );
266
                $transcriptElement->addAttribute('language', $podcast->language_code);
267
268
269
            }

            if ($episode->chapters_file_url) {
270
271
272
                $chaptersElement = $item->addChild('chapters', null, $podcastNamespace);
                $chaptersElement->addAttribute('url', $episode->chapters_file_url);
                $chaptersElement->addAttribute('type', 'application/json+chapters');
273
274
275
            }

            foreach ($episode->soundbites as $soundbite) {
276
                $soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace);
277
278
                $soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time);
                $soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
279
280
            }

281
282
283
284
285
286
287
288
289
            foreach ($episode->persons as $person) {
                foreach ($person->roles as $role) {
                    $personElement = $item->addChild(
                        'person',
                        htmlspecialchars($person->full_name),
                        $podcastNamespace,
                    );

                    $personElement->addAttribute(
290
291
                        'role',
                        htmlspecialchars(
292
                            lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en'),
293
294
                        ),
                    );
295
296

                    $personElement->addAttribute(
297
                        'group',
298
                        htmlspecialchars(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')),
299
                    );
300

301
                    $personElement->addAttribute('img', $person->image->large_url);
302
303

                    if ($person->information_url !== null) {
304
                        $personElement->addAttribute('href', $person->information_url);
305
                    }
306
307
308
309
                }
            }

            $episode->is_blocked &&
310
                $item->addChild('block', 'Yes', $itunesNamespace);
311

312
            if ($episode->custom_rss !== null) {
313
314
                array_to_rss([
                    'elements' => $episode->custom_rss,
315
                ], $item);
316
317
318
319
320
            }
        }

        return $rss->asXML();
    }
321
}
322

323
if (! function_exists('add_category_tag')) {
324
325
326
327
328
    /**
     * Adds <itunes:category> and <category> tags to node for a given category
     */
    function add_category_tag(SimpleXMLElement $node, Category $category): void
    {
329
        $itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
330

331
332
        $itunesCategory = $node->addChild('category', '', $itunesNamespace);
        $itunesCategory->addAttribute(
333
334
335
336
            'text',
            $category->parent !== null
                ? $category->parent->apple_category
                : $category->apple_category,
337
        );
338
339

        if ($category->parent !== null) {
340
341
            $itunesCategoryChild = $itunesCategory->addChild('category', '', $itunesNamespace);
            $itunesCategoryChild->addAttribute('text', $category->apple_category);
342
343
344
            $node->addChild('category', $category->parent->apple_category);
        }
        $node->addChild('category', $category->apple_category);
345
346
    }
}
347

348
if (! function_exists('rss_to_array')) {
349
350
351
    /**
     * Converts XML to array
     *
352
353
354
     * FIXME: param should be SimpleRSSElement
     *
     * @return array<string, mixed>
355
     */
356
    function rss_to_array(SimpleXMLElement $rssNode): array
357
358
359
360
361
362
363
    {
        $nameSpaces = [
            '',
            'http://www.itunes.com/dtds/podcast-1.0.dtd',
            'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
        ];
        $arrayNode = [];
364
365
366
        $arrayNode['name'] = $rssNode->getName();
        $arrayNode['namespace'] = $rssNode->getNamespaces(false);
        foreach ($rssNode->attributes() as $key => $value) {
367
368
            $arrayNode['attributes'][$key] = (string) $value;
        }
369
        $textcontent = trim((string) $rssNode);
370
371
        if (strlen($textcontent) > 0) {
            $arrayNode['content'] = $textcontent;
372
        }
373
        foreach ($nameSpaces as $currentNameSpace) {
374
            foreach ($rssNode->children($currentNameSpace) as $childXmlNode) {
375
376
377
378
379
                $arrayNode['elements'][] = rss_to_array($childXmlNode);
            }
        }

        return $arrayNode;
380
381
382
    }
}

383
if (! function_exists('array_to_rss')) {
384
385
386
    /**
     * Inserts array (converted to XML node) in XML node
     *
387
     * @param array<string, mixed> $arrayNode
388
389
     * @param SimpleRSSElement $xmlNode The XML parent node where this arrayNode should be attached
     */
390
391
    function array_to_rss(array $arrayNode, SimpleRSSElement &$xmlNode): SimpleRSSElement
    {
392
393
394
395
396
        if (array_key_exists('elements', $arrayNode)) {
            foreach ($arrayNode['elements'] as $childArrayNode) {
                $childXmlNode = $xmlNode->addChild(
                    $childArrayNode['name'],
                    $childArrayNode['content'] ?? null,
397
398
399
                    $childArrayNode['namespace'] === []
                        ? null
                        : current($childArrayNode['namespace'])
400
401
402
403
404
405
                );
                if (array_key_exists('attributes', $childArrayNode)) {
                    foreach (
                        $childArrayNode['attributes']
                        as $attributeKey => $attributeValue
                    ) {
406
                        $childXmlNode->addAttribute($attributeKey, $attributeValue);
407
                    }
408
                }
409
                array_to_rss($childArrayNode, $childXmlNode);
410
411
            }
        }
412
413

        return $xmlNode;
414
415
    }
}