Skip to content

Commit 5090e05

Browse files
authored
Merge branch 'master' into feature/IHRACP-9133-ppp-python-dateutil
2 parents 6212ea6 + 3d55156 commit 5090e05

3 files changed

Lines changed: 276 additions & 3 deletions

File tree

‎pypodcastparser/Item.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def __init__(self, soup):
174174
continue
175175
try:
176176
# Using get instead of pop since there can be multiple transcript tags (meaning we don't want to get rid of method after use)
177-
if c.name == "transcript":
177+
if c.name == "transcript" or c.name == "alternateEnclosure":
178178
tag_method = tag_methods.get((c.prefix, c.name))
179179
else:
180180
# Pop method to skip duplicated tag on invalid feeds
@@ -230,7 +230,10 @@ def to_dict(self):
230230
item["episode_title"] = self.title
231231
item["interactive"] = self.interactive
232232
item["external_url"] = self.enclosure_url
233+
item["enclosure_type"] = self.enclosure_type
234+
item["enclosure_length"] = self.enclosure_length
233235
item["transcription"] = self.podcast_transcript
236+
item["alternate_enclosures"] = self.alternate_enclosures
234237

235238
return item
236239

@@ -536,7 +539,7 @@ def set_interactive(self, tag):
536539
raise InvalidPodcastFeed(
537540
f"Invalid Podcast Feed, episode level ihr:interactive: {tag.string}, could not be parsed"
538541
)
539-
542+
540543
def set_alternate_enclosure(self, tag):
541544
"""Parses podcast:alternateEnclosure and its nested podcast:source elements.
542545
@@ -611,4 +614,4 @@ def set_alternate_enclosure(self, tag):
611614
except Exception:
612615
raise InvalidPodcastFeed(
613616
"Invalid Podcast Feed, episode level podcast:alternateEnclosure could not be parsed"
614-
)
617+
)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<rss version="2.0"
3+
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
4+
xmlns:podcast="https://podcastindex.org/namespace/1.0">
5+
<channel>
6+
<title>Alternate Enclosure Test Podcast</title>
7+
<description>Test podcast for alternateEnclosure parsing</description>
8+
<link>https://example.com</link>
9+
<language>en-us</language>
10+
<itunes:author>Test Author</itunes:author>
11+
<itunes:explicit>no</itunes:explicit>
12+
13+
<item>
14+
<title>Episode with Multiple Alternate Enclosures</title>
15+
<description>This episode has multiple media versions</description>
16+
<pubDate>Tue, 07 Jan 2025 10:00:00 EST</pubDate>
17+
<guid>episode-001</guid>
18+
19+
<!-- Standard enclosure (required for backward compatibility) -->
20+
<enclosure url="https://example.com/episode001.mp3" length="43200000" type="audio/mpeg" />
21+
22+
<!-- Same content via multiple transports -->
23+
<podcast:alternateEnclosure
24+
type="audio/mpeg"
25+
length="43200000"
26+
bitrate="128000"
27+
default="true"
28+
title="Standard MP3">
29+
<podcast:source uri="https://example.com/episode001.mp3" />
30+
<podcast:source uri="ipfs://QmdwGqd3d2gFPGeJNLLCshdiPert45fMu84552Y4XHTy4y" />
31+
<podcast:source uri="https://example.com/episode001.torrent" contentType="application/x-bittorrent" />
32+
<podcast:integrity type="sri" value="sha384-ExVqijpSE+cRZuKN5LoVBBsPA6dyjmRH5cXjqfiDD8fmtXi3pdb+qOiFvQdkWh1R" />
33+
</podcast:alternateEnclosure>
34+
35+
<!-- High quality Opus version -->
36+
<podcast:alternateEnclosure
37+
type="audio/opus"
38+
length="32400000"
39+
bitrate="96000"
40+
title="High Quality Opus">
41+
<podcast:source uri="https://example.com/episode001.opus" />
42+
<podcast:source uri="ipfs://QmHighQualityOpusFile123456789" />
43+
</podcast:alternateEnclosure>
44+
45+
<!-- Video version at 1080p -->
46+
<podcast:alternateEnclosure
47+
type="video/mp4"
48+
length="10562995"
49+
bitrate="681483.55"
50+
height="1080"
51+
codecs="avc1.64001f, mp4a.40.2"
52+
lang="en">
53+
<podcast:source uri="https://example.com/episode001-1080.mp4" />
54+
<podcast:source uri="ipfs://QmVideoFile1080pHashHere" />
55+
<podcast:source uri="http://example.onion/episode001-1080.mp4" />
56+
</podcast:alternateEnclosure>
57+
58+
<!-- HLS streaming version -->
59+
<podcast:alternateEnclosure
60+
type="application/x-mpegURL"
61+
length="10562995"
62+
title="HLS Stream">
63+
<podcast:source uri="https://example.com/episode001/master.m3u8" />
64+
</podcast:alternateEnclosure>
65+
66+
<itunes:duration>45:00</itunes:duration>
67+
<itunes:explicit>no</itunes:explicit>
68+
</item>
69+
70+
<item>
71+
<title>Episode with Behind the Scenes Content</title>
72+
<description>This episode has main content plus bonus material</description>
73+
<pubDate>Mon, 06 Jan 2025 10:00:00 EST</pubDate>
74+
<guid>episode-002</guid>
75+
76+
<!-- Standard enclosure -->
77+
<enclosure url="https://example.com/episode002.mp3" length="36000000" type="audio/mpeg" />
78+
79+
<!-- Main episode content -->
80+
<podcast:alternateEnclosure
81+
type="audio/mpeg"
82+
length="36000000"
83+
bitrate="128000"
84+
default="true">
85+
<podcast:source uri="https://example.com/episode002.mp3" />
86+
</podcast:alternateEnclosure>
87+
88+
<!-- Behind the scenes bonus content (different rel) -->
89+
<podcast:alternateEnclosure
90+
type="audio/mpeg"
91+
length="12000000"
92+
bitrate="128000"
93+
title="Behind the Scenes"
94+
rel="bonus">
95+
<podcast:source uri="https://example.com/episode002-bts.mp3" />
96+
</podcast:alternateEnclosure>
97+
98+
<itunes:duration>37:30</itunes:duration>
99+
<itunes:explicit>no</itunes:explicit>
100+
</item>
101+
102+
<item>
103+
<title>Episode with No Alternate Enclosures</title>
104+
<description>This episode only has standard enclosure</description>
105+
<pubDate>Sun, 05 Jan 2025 10:00:00 EST</pubDate>
106+
<guid>episode-003</guid>
107+
108+
<!-- Only standard enclosure, no alternateEnclosure -->
109+
<enclosure url="https://example.com/episode003.mp3" length="28800000" type="audio/mpeg" />
110+
111+
<itunes:duration>30:00</itunes:duration>
112+
<itunes:explicit>no</itunes:explicit>
113+
</item>
114+
</channel>
115+
</rss>

‎tests/test_pyPodcastParser.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,5 +654,160 @@ def test_invalid_episode_dates(self):
654654
)
655655

656656

657+
class TestAlternateEnclosureFeed(unittest.TestCase):
658+
def setUp(self):
659+
test_dir = os.path.dirname(__file__)
660+
test_feeds_dir = os.path.join(test_dir, "test_feeds")
661+
alt_enclosure_path = os.path.join(test_feeds_dir, "alternate_enclosure.rss")
662+
alt_enclosure_file = open(alt_enclosure_path, "rb")
663+
self.alt_enclosure_feed = alt_enclosure_file.read()
664+
self.podcast = Podcast.Podcast(self.alt_enclosure_feed)
665+
666+
def test_item_count(self):
667+
"""Test that all three items are parsed"""
668+
self.assertEqual(len(self.podcast.items), 3)
669+
670+
def test_first_item_has_alternate_enclosures(self):
671+
"""Test that first item has multiple alternate enclosures"""
672+
item = self.podcast.items[0]
673+
self.assertIsNotNone(item.alternate_enclosures)
674+
self.assertEqual(len(item.alternate_enclosures), 4)
675+
676+
def test_standard_enclosure_still_works(self):
677+
"""Test that standard enclosure attributes are still parsed"""
678+
item = self.podcast.items[0]
679+
self.assertEqual(item.enclosure_url, "https://example.com/episode001.mp3")
680+
self.assertEqual(item.enclosure_type, "audio/mpeg")
681+
self.assertEqual(item.enclosure_length, 43200000)
682+
683+
def test_mp3_alternate_enclosure_attributes(self):
684+
"""Test MP3 alternate enclosure with default=true"""
685+
item = self.podcast.items[0]
686+
mp3_enclosure = item.alternate_enclosures[0]
687+
688+
self.assertEqual(mp3_enclosure["mime_type"], "audio/mpeg")
689+
self.assertEqual(mp3_enclosure["length"], 43200000)
690+
self.assertEqual(mp3_enclosure["bitrate"], 128000.0)
691+
self.assertEqual(mp3_enclosure["default"], True)
692+
self.assertEqual(mp3_enclosure["title"], "Standard MP3")
693+
694+
def test_mp3_alternate_enclosure_sources(self):
695+
"""Test MP3 alternate enclosure has multiple sources"""
696+
item = self.podcast.items[0]
697+
mp3_enclosure = item.alternate_enclosures[0]
698+
699+
self.assertEqual(len(mp3_enclosure["sources"]), 3)
700+
701+
# Check HTTPS source - all sources should have the same integrity
702+
self.assertEqual(mp3_enclosure["sources"][0]["uri"], "https://example.com/episode001.mp3")
703+
self.assertIsNone(mp3_enclosure["sources"][0]["content_type"])
704+
self.assertEqual(mp3_enclosure["sources"][0]["integrity_type"], "sri")
705+
self.assertEqual(mp3_enclosure["sources"][0]["integrity_value"], "sha384-ExVqijpSE+cRZuKN5LoVBBsPA6dyjmRH5cXjqfiDD8fmtXi3pdb+qOiFvQdkWh1R")
706+
707+
# Check IPFS source - should also have integrity
708+
self.assertEqual(mp3_enclosure["sources"][1]["uri"], "ipfs://QmdwGqd3d2gFPGeJNLLCshdiPert45fMu84552Y4XHTy4y")
709+
self.assertEqual(mp3_enclosure["sources"][1]["integrity_type"], "sri")
710+
self.assertEqual(mp3_enclosure["sources"][1]["integrity_value"], "sha384-ExVqijpSE+cRZuKN5LoVBBsPA6dyjmRH5cXjqfiDD8fmtXi3pdb+qOiFvQdkWh1R")
711+
712+
# Check torrent source - should also have integrity
713+
self.assertEqual(mp3_enclosure["sources"][2]["uri"], "https://example.com/episode001.torrent")
714+
self.assertEqual(mp3_enclosure["sources"][2]["content_type"], "application/x-bittorrent")
715+
self.assertEqual(mp3_enclosure["sources"][2]["integrity_type"], "sri")
716+
self.assertEqual(mp3_enclosure["sources"][2]["integrity_value"], "sha384-ExVqijpSE+cRZuKN5LoVBBsPA6dyjmRH5cXjqfiDD8fmtXi3pdb+qOiFvQdkWh1R")
717+
718+
def test_mp3_alternate_enclosure_integrity(self):
719+
"""Test MP3 alternate enclosure has integrity in all sources"""
720+
item = self.podcast.items[0]
721+
mp3_enclosure = item.alternate_enclosures[0]
722+
723+
# Check that all sources have the same integrity values
724+
for source in mp3_enclosure["sources"]:
725+
self.assertIsNotNone(source["integrity_type"])
726+
self.assertIsNotNone(source["integrity_value"])
727+
self.assertEqual(source["integrity_type"], "sri")
728+
self.assertEqual(source["integrity_value"], "sha384-ExVqijpSE+cRZuKN5LoVBBsPA6dyjmRH5cXjqfiDD8fmtXi3pdb+qOiFvQdkWh1R")
729+
730+
def test_opus_alternate_enclosure(self):
731+
"""Test Opus alternate enclosure parsing"""
732+
item = self.podcast.items[0]
733+
opus_enclosure = item.alternate_enclosures[1]
734+
735+
self.assertEqual(opus_enclosure["mime_type"], "audio/opus")
736+
self.assertEqual(opus_enclosure["length"], 32400000)
737+
self.assertEqual(opus_enclosure["bitrate"], 96000.0)
738+
self.assertEqual(opus_enclosure["title"], "High Quality Opus")
739+
self.assertEqual(len(opus_enclosure["sources"]), 2)
740+
741+
def test_video_alternate_enclosure(self):
742+
"""Test video alternate enclosure with height, codecs, and lang"""
743+
item = self.podcast.items[0]
744+
video_enclosure = item.alternate_enclosures[2]
745+
746+
self.assertEqual(video_enclosure["mime_type"], "video/mp4")
747+
self.assertEqual(video_enclosure["length"], 10562995)
748+
self.assertEqual(video_enclosure["bitrate"], 681483.55)
749+
self.assertEqual(video_enclosure["height"], 1080)
750+
self.assertEqual(video_enclosure["codecs"], "avc1.64001f, mp4a.40.2")
751+
self.assertEqual(video_enclosure["lang"], "en")
752+
self.assertEqual(len(video_enclosure["sources"]), 3)
753+
754+
# Check Tor source
755+
self.assertIn("example.onion", video_enclosure["sources"][2]["uri"])
756+
757+
def test_hls_alternate_enclosure(self):
758+
"""Test HLS streaming alternate enclosure"""
759+
item = self.podcast.items[0]
760+
hls_enclosure = item.alternate_enclosures[3]
761+
762+
self.assertEqual(hls_enclosure["mime_type"], "application/x-mpegURL")
763+
self.assertEqual(hls_enclosure["title"], "HLS Stream")
764+
self.assertEqual(len(hls_enclosure["sources"]), 1)
765+
self.assertIn("master.m3u8", hls_enclosure["sources"][0]["uri"])
766+
767+
def test_second_item_with_rel_attribute(self):
768+
"""Test second item with bonus content (different rel)"""
769+
item = self.podcast.items[1]
770+
self.assertEqual(len(item.alternate_enclosures), 2)
771+
772+
# Check main content
773+
main_enclosure = item.alternate_enclosures[0]
774+
self.assertEqual(main_enclosure["default"], True)
775+
self.assertIsNone(main_enclosure["rel"])
776+
777+
# Check bonus content
778+
bonus_enclosure = item.alternate_enclosures[1]
779+
self.assertEqual(bonus_enclosure["title"], "Behind the Scenes")
780+
self.assertEqual(bonus_enclosure["rel"], "bonus")
781+
self.assertEqual(bonus_enclosure["length"], 12000000)
782+
783+
def test_third_item_no_alternate_enclosures(self):
784+
"""Test third item has no alternate enclosures"""
785+
item = self.podcast.items[2]
786+
self.assertEqual(len(item.alternate_enclosures), 0)
787+
788+
# But standard enclosure should still work
789+
self.assertEqual(item.enclosure_url, "https://example.com/episode003.mp3")
790+
self.assertEqual(item.enclosure_length, 28800000)
791+
792+
def test_to_dict_includes_alternate_enclosures(self):
793+
"""Test that to_dict() includes alternate_enclosures"""
794+
item = self.podcast.items[0]
795+
item_dict = item.to_dict()
796+
797+
self.assertIn("alternate_enclosures", item_dict)
798+
self.assertEqual(len(item_dict["alternate_enclosures"]), 4)
799+
800+
def test_optional_attributes_can_be_none(self):
801+
"""Test that optional attributes are None when not present"""
802+
item = self.podcast.items[0]
803+
mp3_enclosure = item.alternate_enclosures[0]
804+
805+
# These attributes weren't specified for MP3 enclosure
806+
self.assertIsNone(mp3_enclosure["height"])
807+
self.assertIsNone(mp3_enclosure["lang"])
808+
self.assertIsNone(mp3_enclosure["codecs"])
809+
self.assertIsNone(mp3_enclosure["rel"])
810+
811+
657812
if __name__ == "__main__":
658813
unittest.main()

0 commit comments

Comments
 (0)