From 06ce4cd0be53c9db47b834a1aa193df6ee609a16 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 23 Feb 2020 19:57:03 +0100 Subject: [PATCH 1/2] Implement episode_number, fixes #104 --- doc/usage_guide/episodes.rst | 31 +++++++++++++++++ doc/usage_guide/podcasts.rst | 7 ++-- podgen/__main__.py | 1 + podgen/episode.py | 39 ++++++++++++++++++++++ podgen/podcast.py | 18 ++++++++++ podgen/tests/test_episode.py | 65 ++++++++++++++++++++++++++++++------ 6 files changed, 148 insertions(+), 13 deletions(-) diff --git a/doc/usage_guide/episodes.rst b/doc/usage_guide/episodes.rst index 80717cb..4676333 100644 --- a/doc/usage_guide/episodes.rst +++ b/doc/usage_guide/episodes.rst @@ -191,6 +191,37 @@ That is, given the example above, the id of ``my_episode`` would be Read more about :attr:`the id attribute `. +Organization of episodes +^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, podcast applications will organize episodes by their publication +date, with the most recent episode at top. In addition to this, many publishers +number their episodes by including a number in the episode titles. +Some also divide their episodes into seasons. +Such titles may look like "S02E04 Example title", to take an example. + +Generally, podcast applications can provide a better presentation when the information is +*structured*, rather than mangled together in the episode titles. Apple +therefore introduced `new ways of specifying season and episode numbers`_ through +separate fields in mid 2017. Unfortunately, `not all podcast applications have +adopted the fields`_, but hopefully that will improve as more publishers use +the new fields. + +The :attr:`~podgen.Episode.season` and :attr:`~podgen.Episode.episode_number` +attributes are used to set this information:: + + my_episode.title = "Example title" + my_episode.season = 2 + my_episode.episode_number = 4 + +The ``episode_number`` attribute is mandatory for full episodes if the podcast +is marked as serial. Otherwise, they are just nice to have. + +.. _new ways of specifying season and episode numbers: https://podnews.net/article/episode-numbers-faq +.. _not all podcast applications have adopted the fields: https://podnews.net/article/episode-number-support-in-podcast-apps + + + Episode's publication date ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/usage_guide/podcasts.rst b/doc/usage_guide/podcasts.rst index 8523e5c..a4c4ace 100644 --- a/doc/usage_guide/podcasts.rst +++ b/doc/usage_guide/podcasts.rst @@ -75,9 +75,10 @@ If your podcast is serial, you can set the :attr:`~podgen.Podcast.is_serial` att .. note:: When :attr:`~podgen.Podcast.is_serial` is set to :data:`True`, - all episodes must be given an episode number. - Additionally, it is recommended that you associate each episode with a season. - This is covered on the next page. + all full episodes must be given an + :attr:`episode number `. Additionally, it is + recommended that you associate each episode with a season. This is covered on + the next page. Optional attributes diff --git a/podgen/__main__.py b/podgen/__main__.py index 75934b8..05371e9 100644 --- a/podgen/__main__.py +++ b/podgen/__main__.py @@ -67,6 +67,7 @@ def main(): e1.id = 'http://lernfunk.de/_MEDIAID_123#1' e1.title = 'First Element' e1.season = 1 + e1.episode_number = 1 e1.summary = htmlencode('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si ista mala sunt, placet. Aut etiam, ut vestitum, sic sententiam habeas aliam diff --git a/podgen/episode.py b/podgen/episode.py index f01823a..881fcb5 100644 --- a/podgen/episode.py +++ b/podgen/episode.py @@ -175,6 +175,8 @@ def __init__(self, **kwargs): self.__position = None + self.__episode_number = None + self.__season = None self.__episode_type = EPISODE_TYPE_FULL @@ -323,6 +325,11 @@ def rss_entry(self): order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) order.text = str(self.__position) + if self.__episode_number is not None: + episode_number = etree.SubElement(entry, '{%s}episode' % ITUNES_NS) + # Convert via int, since we stored the original as-is + episode_number.text = str(int(self.__episode_number)) + if self.subtitle: subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) subtitle.text = self.subtitle @@ -627,3 +634,35 @@ def position(self, position): self.__position = int(position) else: self.__position = None + + @property + def episode_number(self): + """This episode's number (within the season). + + This number is used to sort the episodes for + :attr:`serial podcasts <.Podcast.is_serial>`. It can also be displayed + to the user as the episode number. For + :attr:`full episodes `, the episode numbers + should be unique within each season. + + This is mandatory for full episodes of serial podcasts. + + :type: :obj:`None` or positive :obj:`int` + :RSS: itunes:episode + """ + return self.__episode_number + + @episode_number.setter + def episode_number(self, episode_number): + if episode_number is not None: + as_integer = int(episode_number) + if 0 < as_integer: + # Store original (not int), to avoid confusion when setting + self.__episode_number = episode_number + else: + raise ValueError( + 'episode_number must be a positive, non-zero integer; not ' + '"%s"' % episode_number + ) + else: + self.__episode_number = None diff --git a/podgen/podcast.py b/podgen/podcast.py index ded0e90..41e96a3 100644 --- a/podgen/podcast.py +++ b/podgen/podcast.py @@ -18,6 +18,8 @@ from datetime import datetime import dateutil.parser import dateutil.tz + +from podgen import EPISODE_TYPE_FULL from podgen.episode import Episode from podgen.warnings import NotSupportedByItunesWarning from podgen.util import ensure_format, formatRFC2822, listToHumanreadableStr, \ @@ -331,6 +333,9 @@ def __init__(self, **kwargs): Set this to :data:`True` to mark the podcast as serial. Keep the default value, :data:`False`, to mark the podcast as episodic. + + When set to :data:`True`, the :attr:`Episode.episode_number` attribute + is mandatory. .. note:: @@ -619,6 +624,19 @@ def _create_rss(self): link_to_hub.attrib['rel'] = 'hub' for entry in self.episodes: + # Do episode checks that depend on information from Podcast + episode_number_is_mandatory = ( + self.is_serial and + entry.episode_type == EPISODE_TYPE_FULL + ) + if episode_number_is_mandatory and entry.episode_number is None: + raise ValueError( + 'The episode_number attribute is mandatory for full ' + 'episodes that belong to a serial podcast; is set to None ' + 'for %r' % entry + ) + + # Generate and add the episode to the RSS item = entry.rss_entry() channel.append(item) diff --git a/podgen/tests/test_episode.py b/podgen/tests/test_episode.py index c125e8f..91eddda 100644 --- a/podgen/tests/test_episode.py +++ b/podgen/tests/test_episode.py @@ -51,11 +51,11 @@ def setUp(self): #Use also the list directly fe = Episode() fg.episodes.append(fe) - fe.id = 'http://lernfunk.de/media/654321/1' + fe.id = 'http://lernfunk.de/media/654321/2' fe.title = 'The Second Episode' fe = fg.add_episode() - fe.id = 'http://lernfunk.de/media/654321/1' + fe.id = 'http://lernfunk.de/media/654321/3' fe.title = 'The Third Episode' self.fg = fg @@ -86,6 +86,7 @@ def test_constructor(self): is_closed_captioned = False position = 3 withhold_from_itunes = True + episode_number = 4 ep = Episode( title=title, @@ -101,6 +102,7 @@ def test_constructor(self): is_closed_captioned=is_closed_captioned, position=position, withhold_from_itunes=withhold_from_itunes, + episode_number=episode_number, ) # Time to check if this works @@ -117,18 +119,17 @@ def test_constructor(self): self.assertEqual(ep.is_closed_captioned, is_closed_captioned) self.assertEqual(ep.position, position) self.assertEqual(ep.withhold_from_itunes, withhold_from_itunes) + self.assertEqual(ep.episode_number, episode_number) def test_constructorUnknownKeyword(self): self.assertRaises(TypeError, Episode, tittel="What is tittel") self.assertRaises(TypeError, Episode, "This is not a keyword") def test_checkItemNumbers(self): - fg = self.fg assert len(fg.episodes) == 3 def test_checkEntryContent(self): - fg = self.fg assert len(fg.episodes) is not None @@ -388,6 +389,50 @@ def test_position(self): itunes_order = self.fe.rss_entry().find("{%s}order" % self.itunes_ns) assert itunes_order is None + def test_episodeNumber(self): + # Don't appear if None (default) + assert self.fe.episode_number is None + assert self.fe.rss_entry().find("{%s}episode" % self.itunes_ns) is None + + # Appear with the right number when set + self.fe.episode_number = 1 + assert self.fe.episode_number == 1 + itunes_episode = self.fe.rss_entry()\ + .find("{%s}episode" % self.itunes_ns) + assert itunes_episode is not None + assert itunes_episode.text == "1" + + # Must be non-zero integer + self.assertRaises(ValueError, setattr, self.fe, "episode_number", 0) + self.assertRaises(ValueError, setattr, self.fe, "episode_number", -1) + self.assertRaises(ValueError, setattr, self.fe, "episode_number", "not a number") + assert self.fe.episode_number == 1 + + def test_episodeNumberMandatoryWhenSerial(self): + # Vary the first episode. Ensure the remaining two don't interfere + for i, episode in enumerate(self.fg.episodes[1:], start=2): + episode.episode_number = i + + # Test that the missing episode_number is reacted on + self.fg.is_serial = True + self.assertRaises((RuntimeError, ValueError), self.fg.rss_str) + + # Does not raise when the episode is a trailer + self.fe.episode_type = EPISODE_TYPE_TRAILER + self.fg.rss_str() + + # Does not raise when the episode is a bonus + self.fe.episode_type = EPISODE_TYPE_BONUS + self.fg.rss_str() + + # Still raises for full episode + self.fe.episode_type = EPISODE_TYPE_FULL + self.assertRaises((RuntimeError, ValueError), self.fg.rss_str) + + # Does not raise when the episode has a number + self.fe.episode_number = 1 + self.fg.rss_str() + def test_mandatoryAttributes(self): ep = Episode() self.assertRaises((RuntimeError, ValueError), ep.rss_entry) @@ -517,7 +562,7 @@ def test_season(self): def get_element(): return self.fe.rss_entry()\ .find("{%s}season" % self.itunes_ns) - + # Starts out as None assert self.fe.season is None @@ -550,20 +595,20 @@ def get_element(): # Gives error when set to zero with self.assertRaises(ValueError): self.fe.season = 0 - + # Gives error when set to negative number with self.assertRaises(ValueError): self.fe.season = -1 - + def test_episodeType(self): def get_element(): return self.fe.rss_entry()\ .find("{%s}episodeType" % self.itunes_ns) - + # Starts out as "full" self.assertEqual(self.fe.episode_type, EPISODE_TYPE_FULL) - + # Not used when set to "full" assert get_element() is None @@ -584,7 +629,7 @@ def get_element(): class IsEpisodeTypeWhenStr: def __str__(self): return EPISODE_TYPE_TRAILER - + self.fe.episode_type = IsEpisodeTypeWhenStr() self.assertEqual(self.fe.episode_type, EPISODE_TYPE_TRAILER) assert get_element() is not None From 0ef0052aa73fb2893ba3c571c69746b110da53c0 Mon Sep 17 00:00:00 2001 From: Thorben Dahl Date: Sun, 23 Feb 2020 20:45:35 +0100 Subject: [PATCH 2/2] Document episode_type in the usage guide --- doc/usage_guide/episodes.rst | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/doc/usage_guide/episodes.rst b/doc/usage_guide/episodes.rst index 4676333..2cfd307 100644 --- a/doc/usage_guide/episodes.rst +++ b/doc/usage_guide/episodes.rst @@ -284,6 +284,59 @@ You can even have multiple authors:: Read more about :attr:`an episode's authors `. +Bonuses and Trailers +^^^^^^^^^^^^^^^^^^^^ + +Sometimes, you may have some bonus material that did not make it into the +published episode, such as a 1-hour interview which was cut down to 10 minutes +for the podcast, or funny outtakes. Or, you may want to generate some hype for an upcoming season +of a podcast ahead of its first episode. + +Bonuses and trailers are added to the podcast the same way regular episodes are +added, but with the :attr:`~podgen.Episode.episode_type` attribute set to a +different value depending on if it is a bonus or a trailer. + +The following constants are used as values of ``episode_type``: + +* Bonus: ``EPISODE_TYPE_BONUS`` +* Trailer: ``EPISODE_TYPE_TRAILER`` +* Full/regular (default): ``EPISODE_TYPE_FULL`` + +The constants can be imported from ``podgen``. Here is an example:: + + from podgen import Podcast, EPISODE_TYPE_BONUS + + # Create the podcast + my_podcast = Podcast() + # Fill in the podcast details + # ... + + # Create the ordinary episode + my_episode = my_podcast.add_episode() + my_episode.title = "The history of Acme Industries" + my_episode.season = 1 + my_episode.episode_number = 9 + + # Create the bonus episode associated with the ordinary episode above + my_bonus = my_podcast.add_episode() + my_bonus.title = "Full interview with John Doe about Acme Industries" + my_bonus.episode_type = EPISODE_TYPE_BONUS + my_bonus.season = 1 + my_bonus.episode_number = 9 + # ... + +:attr:`~podgen.Episode.episode_type` combines with :attr:`~podgen.Episode.season` +and :attr:`~podgen.Episode.episode_number` to indicate what this is a bonus or trailer for. + +* If you specify an :attr:`episode number `, + optionally with a :attr:`season number ` if you divide episodes by season, + it will be a bonus or trailer for that episode. + You can see this in the example above. +* If you specify only a :attr:`~podgen.Episode.season`, then it will be a bonus or trailer for that season. +* If you specify none of those, + it will be a bonus or trailer for the podcast itself. + + Less used attributes ^^^^^^^^^^^^^^^^^^^^