Skip to content

Commit

Permalink
Implement episode_number, fixes #104
Browse files Browse the repository at this point in the history
  • Loading branch information
tobinus committed Feb 23, 2020
1 parent 4d96189 commit 06ce4cd
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 13 deletions.
31 changes: 31 additions & 0 deletions doc/usage_guide/episodes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,37 @@ That is, given the example above, the id of ``my_episode`` would be

Read more about :attr:`the id attribute <podgen.Episode.id>`.

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
^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
7 changes: 4 additions & 3 deletions doc/usage_guide/podcasts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <podgen.Episode.episode_number>`. Additionally, it is
recommended that you associate each episode with a season. This is covered on
the next page.


Optional attributes
Expand Down
1 change: 1 addition & 0 deletions podgen/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions podgen/episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ def __init__(self, **kwargs):

self.__position = None

self.__episode_number = None

self.__season = None

self.__episode_type = EPISODE_TYPE_FULL
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <episode_type>`, 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
18 changes: 18 additions & 0 deletions podgen/podcast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand Down Expand Up @@ -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::
Expand Down Expand Up @@ -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)

Expand Down
65 changes: 55 additions & 10 deletions podgen/tests/test_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down

0 comments on commit 06ce4cd

Please sign in to comment.