diff options
Diffstat (limited to 'feedgen/ext')
-rw-r--r-- | feedgen/ext/__init__.py | 6 | ||||
-rw-r--r-- | feedgen/ext/base.py | 44 | ||||
-rw-r--r-- | feedgen/ext/dc.py | 407 | ||||
-rw-r--r-- | feedgen/ext/geo.py | 21 | ||||
-rw-r--r-- | feedgen/ext/geo_entry.py | 329 | ||||
-rw-r--r-- | feedgen/ext/media.py | 183 | ||||
-rw-r--r-- | feedgen/ext/podcast.py | 388 | ||||
-rw-r--r-- | feedgen/ext/podcast_entry.py | 321 | ||||
-rw-r--r-- | feedgen/ext/syndication.py | 60 | ||||
-rw-r--r-- | feedgen/ext/torrent.py | 126 |
10 files changed, 1885 insertions, 0 deletions
diff --git a/feedgen/ext/__init__.py b/feedgen/ext/__init__.py new file mode 100644 index 0000000..0e2b628 --- /dev/null +++ b/feedgen/ext/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +""" + =========== + feedgen.ext + =========== +""" diff --git a/feedgen/ext/base.py b/feedgen/ext/base.py new file mode 100644 index 0000000..b94826d --- /dev/null +++ b/feedgen/ext/base.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.base + ~~~~~~~~~~~~~~~~ + + Basic FeedGenerator extension which does nothing but provides all necessary + methods. + + :copyright: 2013, Lars Kiesow <lkiesow@uos.de> + + :license: FreeBSD and LGPL, see license.* for more details. +''' + + +class BaseExtension(object): + '''Basic FeedGenerator extension. + ''' + def extend_ns(self): + '''Returns a dict that will be used in the namespace map for the feed. + ''' + return dict() + + def extend_rss(self, feed): + '''Extend a RSS feed xml structure containing all previously set + fields. + + :param feed: The feed xml root element. + :returns: The feed root element. + ''' + return feed + + def extend_atom(self, feed): + '''Extend an ATOM feed xml structure containing all previously set + fields. + + :param feed: The feed xml root element. + :returns: The feed root element. + ''' + return feed + + +class BaseEntryExtension(BaseExtension): + '''Basic FeedEntry extension. + ''' diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py new file mode 100644 index 0000000..466c66d --- /dev/null +++ b/feedgen/ext/dc.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.dc + ~~~~~~~~~~~~~~~~~~~ + + Extends the FeedGenerator to add Dubline Core Elements to the feeds. + + Descriptions partly taken from + http://dublincore.org/documents/dcmi-terms/#elements-coverage + + :copyright: 2013-2017, Lars Kiesow <lkiesow@uos.de> + + :license: FreeBSD and LGPL, see license.* for more details. +''' + +from feedgen.ext.base import BaseExtension +from feedgen.util import xml_elem + + +class DcBaseExtension(BaseExtension): + '''Dublin Core Elements extension for podcasts. + ''' + + def __init__(self): + # http://dublincore.org/documents/usageguide/elements.shtml + # http://dublincore.org/documents/dces/ + # http://dublincore.org/documents/dcmi-terms/ + self._dcelem_contributor = None + self._dcelem_coverage = None + self._dcelem_creator = None + self._dcelem_date = None + self._dcelem_description = None + self._dcelem_format = None + self._dcelem_identifier = None + self._dcelem_language = None + self._dcelem_publisher = None + self._dcelem_relation = None + self._dcelem_rights = None + self._dcelem_source = None + self._dcelem_subject = None + self._dcelem_title = None + self._dcelem_type = None + + def extend_ns(self): + return {'dc': 'http://purl.org/dc/elements/1.1/'} + + def _extend_xml(self, xml_element): + '''Extend xml_element with set DC fields. + + :param xml_element: etree element + ''' + DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/' + + for elem in ['contributor', 'coverage', 'creator', 'date', + 'description', 'language', 'publisher', 'relation', + 'rights', 'source', 'subject', 'title', 'type', 'format', + 'identifier']: + if hasattr(self, '_dcelem_%s' % elem): + for val in getattr(self, '_dcelem_%s' % elem) or []: + node = xml_elem('{%s}%s' % (DCELEMENTS_NS, elem), + xml_element) + node.text = val + + def extend_atom(self, atom_feed): + '''Extend an Atom feed with the set DC fields. + + :param atom_feed: The feed root element + :returns: The feed root element + ''' + + self._extend_xml(atom_feed) + + return atom_feed + + def extend_rss(self, rss_feed): + '''Extend a RSS feed with the set DC fields. + + :param rss_feed: The feed root element + :returns: The feed root element. + ''' + channel = rss_feed[0] + self._extend_xml(channel) + + return rss_feed + + def dc_contributor(self, contributor=None, replace=False): + '''Get or set the dc:contributor which is an entity responsible for + making contributions to the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-contributor + + :param contributor: Contributor or list of contributors. + :param replace: Replace already set contributors (default: False). + :returns: List of contributors. + ''' + if contributor is not None: + if not isinstance(contributor, list): + contributor = [contributor] + if replace or not self._dcelem_contributor: + self._dcelem_contributor = [] + self._dcelem_contributor += contributor + return self._dcelem_contributor + + def dc_coverage(self, coverage=None, replace=True): + '''Get or set the dc:coverage which indicated the spatial or temporal + topic of the resource, the spatial applicability of the resource, or + the jurisdiction under which the resource is relevant. + + Spatial topic and spatial applicability may be a named place or a + location specified by its geographic coordinates. Temporal topic may be + a named period, date, or date range. A jurisdiction may be a named + administrative entity or a geographic place to which the resource + applies. Recommended best practice is to use a controlled vocabulary + such as the Thesaurus of Geographic Names [TGN]. Where appropriate, + named places or time periods can be used in preference to numeric + identifiers such as sets of coordinates or date ranges. + + References: + [TGN] http://www.getty.edu/research/tools/vocabulary/tgn/index.html + + :param coverage: Coverage of the feed. + :param replace: Replace already set coverage (default: True). + :returns: Coverage of the feed. + ''' + if coverage is not None: + if not isinstance(coverage, list): + coverage = [coverage] + if replace or not self._dcelem_coverage: + self._dcelem_coverage = [] + self._dcelem_coverage = coverage + return self._dcelem_coverage + + def dc_creator(self, creator=None, replace=False): + '''Get or set the dc:creator which is an entity primarily responsible + for making the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-creator + + :param creator: Creator or list of creators. + :param replace: Replace already set creators (default: False). + :returns: List of creators. + ''' + if creator is not None: + if not isinstance(creator, list): + creator = [creator] + if replace or not self._dcelem_creator: + self._dcelem_creator = [] + self._dcelem_creator += creator + return self._dcelem_creator + + def dc_date(self, date=None, replace=True): + '''Get or set the dc:date which describes a point or period of time + associated with an event in the lifecycle of the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-date + + :param date: Date or list of dates. + :param replace: Replace already set dates (default: True). + :returns: List of dates. + ''' + if date is not None: + if not isinstance(date, list): + date = [date] + if replace or not self._dcelem_date: + self._dcelem_date = [] + self._dcelem_date += date + return self._dcelem_date + + def dc_description(self, description=None, replace=True): + '''Get or set the dc:description which is an account of the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-description + + :param description: Description or list of descriptions. + :param replace: Replace already set descriptions (default: True). + :returns: List of descriptions. + ''' + if description is not None: + if not isinstance(description, list): + description = [description] + if replace or not self._dcelem_description: + self._dcelem_description = [] + self._dcelem_description += description + return self._dcelem_description + + def dc_format(self, format=None, replace=True): + '''Get or set the dc:format which describes the file format, physical + medium, or dimensions of the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-format + + :param format: Format of the resource or list of formats. + :param replace: Replace already set format (default: True). + :returns: Format of the resource. + ''' + if format is not None: + if not isinstance(format, list): + format = [format] + if replace or not self._dcelem_format: + self._dcelem_format = [] + self._dcelem_format += format + return self._dcelem_format + + def dc_identifier(self, identifier=None, replace=True): + '''Get or set the dc:identifier which should be an unambiguous + reference to the resource within a given context. + + For more inidentifierion see: + http://dublincore.org/documents/dcmi-terms/#elements-identifier + + :param identifier: Identifier of the resource or list of identifiers. + :param replace: Replace already set identifier (default: True). + :returns: Identifiers of the resource. + ''' + if identifier is not None: + if not isinstance(identifier, list): + identifier = [identifier] + if replace or not self._dcelem_identifier: + self._dcelem_identifier = [] + self._dcelem_identifier += identifier + return self._dcelem_identifier + + def dc_language(self, language=None, replace=True): + '''Get or set the dc:language which describes a language of the + resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-language + + :param language: Language or list of languages. + :param replace: Replace already set languages (default: True). + :returns: List of languages. + ''' + if language is not None: + if not isinstance(language, list): + language = [language] + if replace or not self._dcelem_language: + self._dcelem_language = [] + self._dcelem_language += language + return self._dcelem_language + + def dc_publisher(self, publisher=None, replace=False): + '''Get or set the dc:publisher which is an entity responsible for + making the resource available. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-publisher + + :param publisher: Publisher or list of publishers. + :param replace: Replace already set publishers (default: False). + :returns: List of publishers. + ''' + if publisher is not None: + if not isinstance(publisher, list): + publisher = [publisher] + if replace or not self._dcelem_publisher: + self._dcelem_publisher = [] + self._dcelem_publisher += publisher + return self._dcelem_publisher + + def dc_relation(self, relation=None, replace=False): + '''Get or set the dc:relation which describes a related resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-relation + + :param relation: Relation or list of relations. + :param replace: Replace already set relations (default: False). + :returns: List of relations. + ''' + if relation is not None: + if not isinstance(relation, list): + relation = [relation] + if replace or not self._dcelem_relation: + self._dcelem_relation = [] + self._dcelem_relation += relation + return self._dcelem_relation + + def dc_rights(self, rights=None, replace=False): + '''Get or set the dc:rights which may contain information about rights + held in and over the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-rights + + :param rights: Rights information or list of rights information. + :param replace: Replace already set rights (default: False). + :returns: List of rights information. + ''' + if rights is not None: + if not isinstance(rights, list): + rights = [rights] + if replace or not self._dcelem_rights: + self._dcelem_rights = [] + self._dcelem_rights += rights + return self._dcelem_rights + + def dc_source(self, source=None, replace=False): + '''Get or set the dc:source which is a related resource from which the + described resource is derived. + + The described resource may be derived from the related resource in + whole or in part. Recommended best practice is to identify the related + resource by means of a string conforming to a formal identification + system. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-source + + :param source: Source or list of sources. + :param replace: Replace already set sources (default: False). + :returns: List of sources. + ''' + if source is not None: + if not isinstance(source, list): + source = [source] + if replace or not self._dcelem_source: + self._dcelem_source = [] + self._dcelem_source += source + return self._dcelem_source + + def dc_subject(self, subject=None, replace=False): + '''Get or set the dc:subject which describes the topic of the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-subject + + :param subject: Subject or list of subjects. + :param replace: Replace already set subjects (default: False). + :returns: List of subjects. + ''' + if subject is not None: + if not isinstance(subject, list): + subject = [subject] + if replace or not self._dcelem_subject: + self._dcelem_subject = [] + self._dcelem_subject += subject + return self._dcelem_subject + + def dc_title(self, title=None, replace=True): + '''Get or set the dc:title which is a name given to the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-title + + :param title: Title or list of titles. + :param replace: Replace already set titles (default: False). + :returns: List of titles. + ''' + if title is not None: + if not isinstance(title, list): + title = [title] + if replace or not self._dcelem_title: + self._dcelem_title = [] + self._dcelem_title += title + return self._dcelem_title + + def dc_type(self, type=None, replace=False): + '''Get or set the dc:type which describes the nature or genre of the + resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-type + + :param type: Type or list of types. + :param replace: Replace already set types (default: False). + :returns: List of types. + ''' + if type is not None: + if not isinstance(type, list): + type = [type] + if replace or not self._dcelem_type: + self._dcelem_type = [] + self._dcelem_type += type + return self._dcelem_type + + +class DcExtension(DcBaseExtension): + '''Dublin Core Elements extension for podcasts. + ''' + + +class DcEntryExtension(DcBaseExtension): + '''Dublin Core Elements extension for podcasts. + ''' + def extend_atom(self, entry): + '''Add dc elements to an atom item. Alters the item itself. + + :param entry: An atom entry element. + :returns: The entry element. + ''' + self._extend_xml(entry) + return entry + + def extend_rss(self, item): + '''Add dc elements to a RSS item. Alters the item itself. + + :param item: A RSS item element. + :returns: The item element. + ''' + self._extend_xml(item) + return item diff --git a/feedgen/ext/geo.py b/feedgen/ext/geo.py new file mode 100644 index 0000000..b6384d4 --- /dev/null +++ b/feedgen/ext/geo.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.geo + ~~~~~~~~~~~~~~~~~~~ + + Extends the FeedGenerator to produce Simple GeoRSS feeds. + + :copyright: 2017, Bob Breznak <bob.breznak@gmail.com> + + :license: FreeBSD and LGPL, see license.* for more details. +''' + +from feedgen.ext.base import BaseExtension + + +class GeoExtension(BaseExtension): + '''FeedGenerator extension for Simple GeoRSS. + ''' + + def extend_ns(self): + return {'georss': 'http://www.georss.org/georss'} diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py new file mode 100644 index 0000000..4c18cfe --- /dev/null +++ b/feedgen/ext/geo_entry.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.geo_entry + ~~~~~~~~~~~~~~~~~~~ + + Extends the FeedGenerator to produce Simple GeoRSS feeds. + + :copyright: 2017, Bob Breznak <bob.breznak@gmail.com> + + :license: FreeBSD and LGPL, see license.* for more details. +''' +import numbers +import warnings + +from feedgen.ext.base import BaseEntryExtension +from feedgen.util import xml_elem + + +class GeoRSSPolygonInteriorWarning(Warning): + """ + Simple placeholder for warning about ignored polygon interiors. + + Stores the original geom on a ``geom`` attribute (if required warnings are + raised as errors). + """ + + def __init__(self, geom, *args, **kwargs): + self.geom = geom + super(GeoRSSPolygonInteriorWarning, self).__init__(*args, **kwargs) + + def __str__(self): + return '{:d} interiors of polygon ignored'.format( + len(self.geom.__geo_interface__['coordinates']) - 1 + ) # ignore exterior in count + + +class GeoRSSGeometryError(ValueError): + """ + Subclass of ValueError for a GeoRSS geometry error + + Only some geometries are supported in Simple GeoRSS, so if not raise an + error. Offending geometry is stored on the ``geom`` attribute. + """ + + def __init__(self, geom, *args, **kwargs): + self.geom = geom + super(GeoRSSGeometryError, self).__init__(*args, **kwargs) + + def __str__(self): + msg = "Geometry of type '{}' not in Point, Linestring or Polygon" + return msg.format( + self.geom.__geo_interface__['type'] + ) + + +class GeoEntryExtension(BaseEntryExtension): + '''FeedEntry extension for Simple GeoRSS. + ''' + + def __init__(self): + '''Simple GeoRSS tag''' + # geometries + self.__point = None + self.__line = None + self.__polygon = None + self.__box = None + + # additional properties + self.__featuretypetag = None + self.__relationshiptag = None + self.__featurename = None + + # elevation + self.__elev = None + self.__floor = None + + # radius + self.__radius = None + + def extend_file(self, entry): + '''Add additional fields to an RSS item. + + :param feed: The RSS item XML element to use. + ''' + + GEO_NS = 'http://www.georss.org/georss' + + if self.__point: + point = xml_elem('{%s}point' % GEO_NS, entry) + point.text = self.__point + + if self.__line: + line = xml_elem('{%s}line' % GEO_NS, entry) + line.text = self.__line + + if self.__polygon: + polygon = xml_elem('{%s}polygon' % GEO_NS, entry) + polygon.text = self.__polygon + + if self.__box: + box = xml_elem('{%s}box' % GEO_NS, entry) + box.text = self.__box + + if self.__featuretypetag: + featuretypetag = xml_elem('{%s}featuretypetag' % GEO_NS, entry) + featuretypetag.text = self.__featuretypetag + + if self.__relationshiptag: + relationshiptag = xml_elem('{%s}relationshiptag' % GEO_NS, entry) + relationshiptag.text = self.__relationshiptag + + if self.__featurename: + featurename = xml_elem('{%s}featurename' % GEO_NS, entry) + featurename.text = self.__featurename + + if self.__elev: + elevation = xml_elem('{%s}elev' % GEO_NS, entry) + elevation.text = str(self.__elev) + + if self.__floor: + floor = xml_elem('{%s}floor' % GEO_NS, entry) + floor.text = str(self.__floor) + + if self.__radius: + radius = xml_elem('{%s}radius' % GEO_NS, entry) + radius.text = str(self.__radius) + + return entry + + def extend_rss(self, entry): + return self.extend_file(entry) + + def extend_atom(self, entry): + return self.extend_file(entry) + + def point(self, point=None): + '''Get or set the georss:point of the entry. + + :param point: The GeoRSS formatted point (i.e. "42.36 -71.05") + :returns: The current georss:point of the entry. + ''' + + if point is not None: + self.__point = point + + return self.__point + + def line(self, line=None): + '''Get or set the georss:line of the entry + + :param point: The GeoRSS formatted line (i.e. "45.256 -110.45 46.46 + -109.48 43.84 -109.86") + :return: The current georss:line of the entry + ''' + if line is not None: + self.__line = line + + return self.__line + + def polygon(self, polygon=None): + '''Get or set the georss:polygon of the entry + + :param polygon: The GeoRSS formatted polygon (i.e. "45.256 -110.45 + 46.46 -109.48 43.84 -109.86 45.256 -110.45") + :return: The current georss:polygon of the entry + ''' + if polygon is not None: + self.__polygon = polygon + + return self.__polygon + + def box(self, box=None): + ''' + Get or set the georss:box of the entry + + :param box: The GeoRSS formatted box (i.e. "42.943 -71.032 43.039 + -69.856") + :return: The current georss:box of the entry + ''' + if box is not None: + self.__box = box + + return self.__box + + def featuretypetag(self, featuretypetag=None): + ''' + Get or set the georss:featuretypetag of the entry + + :param featuretypetag: The GeoRSS feaaturertyptag (e.g. "city") + :return: The current georss:featurertypetag + ''' + if featuretypetag is not None: + self.__featuretypetag = featuretypetag + + return self.__featuretypetag + + def relationshiptag(self, relationshiptag=None): + ''' + Get or set the georss:relationshiptag of the entry + + :param relationshiptag: The GeoRSS relationshiptag (e.g. + "is-centred-at") + :return: the current georss:relationshiptag + ''' + if relationshiptag is not None: + self.__relationshiptag = relationshiptag + + return self.__relationshiptag + + def featurename(self, featurename=None): + ''' + Get or set the georss:featurename of the entry + + :param featuretypetag: The GeoRSS featurename (e.g. "Footscray") + :return: the current georss:featurename + ''' + if featurename is not None: + self.__featurename = featurename + + return self.__featurename + + def elev(self, elev=None): + ''' + Get or set the georss:elev of the entry + + :param elev: The GeoRSS elevation (e.g. 100.3) + :type elev: numbers.Number + :return: the current georss:elev + ''' + if elev is not None: + if not isinstance(elev, numbers.Number): + raise ValueError("elev tag must be numeric: {}".format(elev)) + + self.__elev = elev + + return self.__elev + + def floor(self, floor=None): + ''' + Get or set the georss:floor of the entry + + :param floor: The GeoRSS floor (e.g. 4) + :type floor: int + :return: the current georss:floor + ''' + if floor is not None: + if not isinstance(floor, int): + raise ValueError("floor tag must be int: {}".format(floor)) + + self.__floor = floor + + return self.__floor + + def radius(self, radius=None): + ''' + Get or set the georss:radius of the entry + + :param radius: The GeoRSS radius (e.g. 100.3) + :type radius: numbers.Number + :return: the current georss:radius + ''' + if radius is not None: + if not isinstance(radius, numbers.Number): + raise ValueError( + "radius tag must be numeric: {}".format(radius) + ) + + self.__radius = radius + + return self.__radius + + def geom_from_geo_interface(self, geom): + ''' + Generate a georss geometry from some Python object with a + ``__geo_interface__`` property (see the `geo_interface specification by + Sean Gillies`_geointerface ) + + Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be + easily converted to GeoRSS: + + - Point + - LineString + - Polygon (if there are holes / donuts in the polygons a warning will + be generated + + Other GeoJson types will raise a ``ValueError``. + + .. note:: The geometry is assumed to be x, y as longitude, latitude in + the WGS84 projection. + + .. _geointerface: https://gist.github.com/sgillies/2217756 + .. _geojson: https://geojson.org/ + + :param geom: Geometry object with a __geo_interface__ property + :return: the formatted GeoRSS geometry + ''' + geojson = geom.__geo_interface__ + + if geojson['type'] not in ('Point', 'LineString', 'Polygon'): + raise GeoRSSGeometryError(geom) + + if geojson['type'] == 'Point': + + coords = '{:f} {:f}'.format( + geojson['coordinates'][1], # latitude is y + geojson['coordinates'][0] + ) + return self.point(coords) + + elif geojson['type'] == 'LineString': + + coords = ' '.join( + '{:f} {:f}'.format(vertex[1], vertex[0]) + for vertex in + geojson['coordinates'] + ) + return self.line(coords) + + elif geojson['type'] == 'Polygon': + + if len(geojson['coordinates']) > 1: + warnings.warn(GeoRSSPolygonInteriorWarning(geom)) + + coords = ' '.join( + '{:f} {:f}'.format(vertex[1], vertex[0]) + for vertex in + geojson['coordinates'][0] + ) + return self.polygon(coords) diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py new file mode 100644 index 0000000..74a5317 --- /dev/null +++ b/feedgen/ext/media.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.media + ~~~~~~~~~~~~~~~~~ + + Extends the feedgen to produce media tags. + + :copyright: 2013-2017, Lars Kiesow <lkiesow@uos.de> + + :license: FreeBSD and LGPL, see license.* for more details. +''' + +from feedgen.ext.base import BaseEntryExtension, BaseExtension +from feedgen.util import ensure_format, xml_elem + +MEDIA_NS = 'http://search.yahoo.com/mrss/' + + +class MediaExtension(BaseExtension): + '''FeedGenerator extension for torrent feeds. + ''' + + def extend_ns(self): + return {'media': MEDIA_NS} + + +class MediaEntryExtension(BaseEntryExtension): + '''FeedEntry extension for media tags. + ''' + + def __init__(self): + self.__media_content = [] + self.__media_thumbnail = [] + + def extend_atom(self, entry): + '''Add additional fields to an RSS item. + + :param feed: The RSS item XML element to use. + ''' + + groups = {None: entry} + for media_content in self.__media_content: + # Define current media:group + group = groups.get(media_content.get('group')) + if group is None: + group = xml_elem('{%s}group' % MEDIA_NS, entry) + groups[media_content.get('group')] = group + # Add content + content = xml_elem('{%s}content' % MEDIA_NS, group) + for attr in ('url', 'fileSize', 'type', 'medium', 'isDefault', + 'expression', 'bitrate', 'framerate', 'samplingrate', + 'channels', 'duration', 'height', 'width', 'lang'): + if media_content.get(attr): + content.set(attr, media_content[attr]) + + for media_thumbnail in self.__media_thumbnail: + # Define current media:group + group = groups.get(media_thumbnail.get('group')) + if group is None: + group = xml_elem('{%s}group' % MEDIA_NS, entry) + groups[media_thumbnail.get('group')] = group + # Add thumbnails + thumbnail = xml_elem('{%s}thumbnail' % MEDIA_NS, group) + for attr in ('url', 'height', 'width', 'time'): + if media_thumbnail.get(attr): + thumbnail.set(attr, media_thumbnail[attr]) + + return entry + + def extend_rss(self, item): + return self.extend_atom(item) + + def content(self, content=None, replace=False, group='default', **kwargs): + '''Get or set media:content data. + + This method can be called with: + - the fields of a media:content as keyword arguments + - the fields of a media:content as a dictionary + - a list of dictionaries containing the media:content fields + + <media:content> is a sub-element of either <item> or <media:group>. + Media objects that are not the same content should not be included in + the same <media:group> element. The sequence of these items implies + the order of presentation. While many of the attributes appear to be + audio/video specific, this element can be used to publish any type + of media. It contains 14 attributes, most of which are optional. + + media:content has the following fields: + - *url* should specify the direct URL to the media object. + - *fileSize* number of bytes of the media object. + - *type* standard MIME type of the object. + - *medium* type of object (image | audio | video | document | + executable). + - *isDefault* determines if this is the default object. + - *expression* determines if the object is a sample or the full version + of the object, or even if it is a continuous stream (sample | full | + nonstop). + - *bitrate* kilobits per second rate of media. + - *framerate* number of frames per second for the media object. + - *samplingrate* number of samples per second taken to create the media + object. It is expressed in thousands of samples per second (kHz). + - *channels* number of audio channels in the media object. + - *duration* number of seconds the media object plays. + - *height* height of the media object. + - *width* width of the media object. + - *lang* is the primary language encapsulated in the media object. + + :param content: Dictionary or list of dictionaries with content data. + :param replace: Add or replace old data. + :param group: Media group to put this content in. + + :returns: The media content tag. + ''' + # Handle kwargs + if content is None and kwargs: + content = kwargs + # Handle new data + if content is not None: + # Reset data if we want to replace them + if replace or self.__media_content is None: + self.__media_content = [] + # Ensure list + if not isinstance(content, list): + content = [content] + # define media group + for c in content: + c['group'] = c.get('group', group) + self.__media_content += ensure_format( + content, + set(['url', 'fileSize', 'type', 'medium', 'isDefault', + 'expression', 'bitrate', 'framerate', 'samplingrate', + 'channels', 'duration', 'height', 'width', 'lang', + 'group']), + set(['url', 'group'])) + return self.__media_content + + def thumbnail(self, thumbnail=None, replace=False, group='default', + **kwargs): + '''Get or set media:thumbnail data. + + This method can be called with: + - the fields of a media:content as keyword arguments + - the fields of a media:content as a dictionary + - a list of dictionaries containing the media:content fields + + Allows particular images to be used as representative images for + the media object. If multiple thumbnails are included, and time + coding is not at play, it is assumed that the images are in order + of importance. It has one required attribute and three optional + attributes. + + media:thumbnail has the following fields: + - *url* should specify the direct URL to the media object. + - *height* height of the media object. + - *width* width of the media object. + - *time* specifies the time offset in relation to the media object. + + :param thumbnail: Dictionary or list of dictionaries with thumbnail + data. + :param replace: Add or replace old data. + :param group: Media group to put this content in. + + :returns: The media thumbnail tag. + ''' + # Handle kwargs + if thumbnail is None and kwargs: + thumbnail = kwargs + # Handle new data + if thumbnail is not None: + # Reset data if we want to replace them + if replace or self.__media_thumbnail is None: + self.__media_thumbnail = [] + # Ensure list + if not isinstance(thumbnail, list): + thumbnail = [thumbnail] + # Define media group + for t in thumbnail: + t['group'] = t.get('group', group) + self.__media_thumbnail += ensure_format( + thumbnail, + set(['url', 'height', 'width', 'time', 'group']), + set(['url', 'group'])) + return self.__media_thumbnail diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py new file mode 100644 index 0000000..c535392 --- /dev/null +++ b/feedgen/ext/podcast.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.podcast + ~~~~~~~~~~~~~~~~~~~ + + Extends the FeedGenerator to produce podcasts. + + :copyright: 2013, Lars Kiesow <lkiesow@uos.de> + + :license: FreeBSD and LGPL, see license.* for more details. +''' + +from feedgen.compat import string_types +from feedgen.ext.base import BaseExtension +from feedgen.util import ensure_format, xml_elem + + +class PodcastExtension(BaseExtension): + '''FeedGenerator extension for podcasts. + ''' + + def __init__(self): + # ITunes tags + # http://www.apple.com/itunes/podcasts/specs.html#rss + self.__itunes_author = None + self.__itunes_block = None + self.__itunes_category = None + self.__itunes_image = None + self.__itunes_explicit = None + self.__itunes_complete = None + self.__itunes_new_feed_url = None + self.__itunes_owner = None + self.__itunes_subtitle = None + self.__itunes_summary = None + self.__itunes_type = None + + def extend_ns(self): + return {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + + def extend_rss(self, rss_feed): + '''Extend an RSS feed root with set itunes fields. + + :returns: The feed root element. + ''' + ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' + channel = rss_feed[0] + + if self.__itunes_author: + author = xml_elem('{%s}author' % ITUNES_NS, channel) + author.text = self.__itunes_author + + if self.__itunes_block is not None: + block = xml_elem('{%s}block' % ITUNES_NS, channel) + block.text = 'yes' if self.__itunes_block else 'no' + + for c in self.__itunes_category or []: + if not c.get('cat'): + continue + category = channel.find( + '{%s}category[@text="%s"]' % (ITUNES_NS, c.get('cat'))) + if category is None: + category = xml_elem('{%s}category' % ITUNES_NS, channel) + category.attrib['text'] = c.get('cat') + + if c.get('sub'): + subcategory = xml_elem('{%s}category' % ITUNES_NS, category) + subcategory.attrib['text'] = c.get('sub') + + if self.__itunes_image: + image = xml_elem('{%s}image' % ITUNES_NS, channel) + image.attrib['href'] = self.__itunes_image + + if self.__itunes_explicit in ('yes', 'no', 'clean'): + explicit = xml_elem('{%s}explicit' % ITUNES_NS, channel) + explicit.text = self.__itunes_explicit + + if self.__itunes_complete in ('yes', 'no'): + complete = xml_elem('{%s}complete' % ITUNES_NS, channel) + complete.text = self.__itunes_complete + + if self.__itunes_new_feed_url: + new_feed_url = xml_elem('{%s}new-feed-url' % ITUNES_NS, channel) + new_feed_url.text = self.__itunes_new_feed_url + + if self.__itunes_owner: + owner = xml_elem('{%s}owner' % ITUNES_NS, channel) + owner_name = xml_elem('{%s}name' % ITUNES_NS, owner) + owner_name.text = self.__itunes_owner.get('name') + owner_email = xml_elem('{%s}email' % ITUNES_NS, owner) + owner_email.text = self.__itunes_owner.get('email') + + if self.__itunes_subtitle: + subtitle = xml_elem('{%s}subtitle' % ITUNES_NS, channel) + subtitle.text = self.__itunes_subtitle + + if self.__itunes_summary: + summary = xml_elem('{%s}summary' % ITUNES_NS, channel) + summary.text = self.__itunes_summary + + if self.__itunes_type in ('episodic', 'serial'): + type = xml_elem('{%s}type' % ITUNES_NS, channel) + type.text = self.__itunes_type + + return rss_feed + + def itunes_author(self, itunes_author=None): + '''Get or set the itunes:author. The content of this tag is shown in + the Artist column in iTunes. If the tag is not present, iTunes uses the + contents of the <author> tag. If <itunes:author> is not present at the + feed level, iTunes will use the contents of <managingEditor>. + + :param itunes_author: The author of the podcast. + :returns: The author of the podcast. + ''' + if itunes_author is not None: + self.__itunes_author = itunes_author + return self.__itunes_author + + def itunes_block(self, itunes_block=None): + '''Get or set the ITunes block attribute. Use this to prevent the + entire podcast from appearing in the iTunes podcast directory. + + :param itunes_block: Block the podcast. + :returns: If the podcast is blocked. + ''' + if itunes_block is not None: + self.__itunes_block = itunes_block + return self.__itunes_block + + def itunes_category(self, itunes_category=None, replace=False, **kwargs): + '''Get or set the ITunes category which appears in the category column + and in iTunes Store Browser. + + The (sub-)category has to be one from the values defined at + http://www.apple.com/itunes/podcasts/specs.html#categories + + This method can be called with: + + - the fields of an itunes_category as keyword arguments + - the fields of an itunes_category as a dictionary + - a list of dictionaries containing the itunes_category fields + + An itunes_category has the following fields: + + - *cat* name for a category. + - *sub* name for a subcategory, child of category + + If a podcast has more than one subcategory from the same category, the + category is called more than once. + + Likei the parameter:: + + [{"cat":"Arts","sub":"Design"},{"cat":"Arts","sub":"Food"}] + + …would become:: + + <itunes:category text="Arts"> + <itunes:category text="Design"/> + <itunes:category text="Food"/> + </itunes:category> + + + :param itunes_category: Dictionary or list of dictionaries with + itunes_category data. + :param replace: Add or replace old data. + :returns: List of itunes_categories as dictionaries. + + --- + + **Important note about deprecated parameter syntax:** Old version of + the feedgen did only support one category plus one subcategory which + would be passed to this ducntion as first two parameters. For + compatibility reasons, this still works but should not be used any may + be removed at any time. + ''' + # Ensure old API still works for now. Note that the API is deprecated + # and this fallback may be removed at any time. + if isinstance(itunes_category, string_types): + itunes_category = {'cat': itunes_category} + if replace: + itunes_category['sub'] = replace + replace = True + if itunes_category is None and kwargs: + itunes_category = kwargs + if itunes_category is not None: + if replace or self.__itunes_category is None: + self.__itunes_category = [] + self.__itunes_category += ensure_format(itunes_category, + set(['cat', 'sub']), + set(['cat'])) + return self.__itunes_category + + def itunes_image(self, itunes_image=None): + '''Get or set the image for the podcast. This tag specifies the artwork + for your podcast. Put the URL to the image in the href attribute. + iTunes prefers square .jpg images that are at least 1400x1400 pixels, + which is different from what is specified for the standard RSS image + tag. In order for a podcast to be eligible for an iTunes Store feature, + the accompanying image must be at least 1400x1400 pixels. + + iTunes supports images in JPEG and PNG formats with an RGB color space + (CMYK is not supported). The URL must end in ".jpg" or ".png". If the + <itunes:image> tag is not present, iTunes will use the contents of the + RSS image tag. + + If you change your podcast’s image, also change the file’s name. iTunes + may not change the image if it checks your feed and the image URL is + the same. The server hosting your cover art image must allow HTTP head + requests for iTS to be able to automatically update your cover art. + + :param itunes_image: Image of the podcast. + :returns: Image of the podcast. + ''' + if itunes_image is not None: + if itunes_image.endswith('.jpg') or itunes_image.endswith('.png'): + self.__itunes_image = itunes_image + else: + ValueError('Image file must be png or jpg') + return self.__itunes_image + + def itunes_explicit(self, itunes_explicit=None): + '''Get or the the itunes:explicit value of the podcast. This tag should + be used to indicate whether your podcast contains explicit material. + The three values for this tag are "yes", "no", and "clean". + + If you populate this tag with "yes", an "explicit" parental advisory + graphic will appear next to your podcast artwork on the iTunes Store + and in the Name column in iTunes. If the value is "clean", the parental + advisory type is considered Clean, meaning that no explicit language or + adult content is included anywhere in the episodes, and a "clean" + graphic will appear. If the explicit tag is present and has any other + value (e.g., "no"), you see no indicator — blank is the default + advisory type. + + :param itunes_explicit: If the podcast contains explicit material. + :returns: If the podcast contains explicit material. + ''' + if itunes_explicit is not None: + if itunes_explicit not in ('', 'yes', 'no', 'clean'): + raise ValueError('Invalid value for explicit tag') + self.__itunes_explicit = itunes_explicit + return self.__itunes_explicit + + def itunes_complete(self, itunes_complete=None): + '''Get or set the itunes:complete value of the podcast. This tag can be + used to indicate the completion of a podcast. + + If you populate this tag with "yes", you are indicating that no more + episodes will be added to the podcast. If the <itunes:complete> tag is + present and has any other value (e.g. “no”), it will have no effect on + the podcast. + + :param itunes_complete: If the podcast is complete. + :returns: If the podcast is complete. + ''' + if itunes_complete is not None: + if itunes_complete not in ('yes', 'no', '', True, False): + raise ValueError('Invalid value for complete tag') + if itunes_complete is True: + itunes_complete = 'yes' + if itunes_complete is False: + itunes_complete = 'no' + self.__itunes_complete = itunes_complete + return self.__itunes_complete + + def itunes_new_feed_url(self, itunes_new_feed_url=None): + '''Get or set the new-feed-url property of the podcast. This tag allows + you to change the URL where the podcast feed is located + + After adding the tag to your old feed, you should maintain the old feed + for 48 hours before retiring it. At that point, iTunes will have + updated the directory with the new feed URL. + + :param itunes_new_feed_url: New feed URL. + :returns: New feed URL. + ''' + if itunes_new_feed_url is not None: + self.__itunes_new_feed_url = itunes_new_feed_url + return self.__itunes_new_feed_url + + def itunes_owner(self, name=None, email=None): + '''Get or set the itunes:owner of the podcast. This tag contains + information that will be used to contact the owner of the podcast for + communication specifically about the podcast. It will not be publicly + displayed. + + :param itunes_owner: The owner of the feed. + :returns: Data of the owner of the feed. + ''' + if name is not None: + if name and email: + self.__itunes_owner = {'name': name, 'email': email} + elif not name and not email: + self.__itunes_owner = None + else: + raise ValueError('Both name and email have to be set.') + return self.__itunes_owner + + def itunes_subtitle(self, itunes_subtitle=None): + '''Get or set the itunes:subtitle value for the podcast. The contents + of this tag are shown in the Description column in iTunes. The subtitle + displays best if it is only a few words long. + + :param itunes_subtitle: Subtitle of the podcast. + :returns: Subtitle of the podcast. + ''' + if itunes_subtitle is not None: + self.__itunes_subtitle = itunes_subtitle + return self.__itunes_subtitle + + def itunes_summary(self, itunes_summary=None): + '''Get or set the itunes:summary value for the podcast. The contents of + this tag are shown in a separate window that appears when the "circled + i" in the Description column is clicked. It also appears on the iTunes + page for your podcast. This field can be up to 4000 characters. If + `<itunes:summary>` is not included, the contents of the <description> + tag are used. + + :param itunes_summary: Summary of the podcast. + :returns: Summary of the podcast. + ''' + if itunes_summary is not None: + self.__itunes_summary = itunes_summary + return self.__itunes_summary + + def itunes_type(self, itunes_type=None): + '''Get or set the itunes:type value of the podcast. This tag should + be used to indicate the type of your podcast. + The two values for this tag are "episodic" and "serial". + + If your show is Serial you must use this tag. + + Specify episodic when episodes are intended to be consumed without any + specific order. Apple Podcasts will present newest episodes first and + display the publish date (required) of each episode. If organized into + seasons, the newest season will be presented first - otherwise, + episodes will be grouped by year published, newest first. + + Specify serial when episodes are intended to be consumed in sequential + order. Apple Podcasts will present the oldest episodes first and + display the episode numbers (required) of each episode. If organized + into seasons, the newest season will be presented first and + <itunes:episode> numbers must be given for each episode. + + :param itunes_type: The type of the podcast + :returns: type of the pdocast. + ''' + if itunes_type is not None: + if itunes_type not in ('episodic', 'serial'): + raise ValueError('Invalid value for type tag') + self.__itunes_type = itunes_type + return self.__itunes_type + + _itunes_categories = { + 'Arts': [ + 'Design', 'Fashion & Beauty', 'Food', 'Literature', + 'Performing Arts', 'Visual Arts'], + 'Business': [ + 'Business News', 'Careers', 'Investing', + 'Management & Marketing', 'Shopping'], + 'Comedy': [], + 'Education': [ + 'Education', 'Education Technology', 'Higher Education', + 'K-12', 'Language Courses', 'Training'], + 'Games & Hobbies': [ + 'Automotive', 'Aviation', 'Hobbies', 'Other Games', + 'Video Games'], + 'Government & Organizations': [ + 'Local', 'National', 'Non-Profit', 'Regional'], + 'Health': [ + 'Alternative Health', 'Fitness & Nutrition', 'Self-Help', + 'Sexuality'], + 'Kids & Family': [], + 'Music': [], + 'News & Politics': [], + 'Religion & Spirituality': [ + 'Buddhism', 'Christianity', 'Hinduism', 'Islam', 'Judaism', + 'Other', 'Spirituality'], + 'Science & Medicine': [ + 'Medicine', 'Natural Sciences', 'Social Sciences'], + 'Society & Culture': [ + 'History', 'Personal Journals', 'Philosophy', + 'Places & Travel'], + 'Sports & Recreation': [ + 'Amateur', 'College & High School', 'Outdoor', 'Professional'], + 'Technology': [ + 'Gadgets', 'Tech News', 'Podcasting', 'Software How-To'], + 'TV & Film': []} diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py new file mode 100644 index 0000000..2d60c2d --- /dev/null +++ b/feedgen/ext/podcast_entry.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.podcast_entry + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + Extends the feedgen to produce podcasts. + + :copyright: 2013-2016, Lars Kiesow <lkiesow@uos.de> + + :license: FreeBSD and LGPL, see license.* for more details. +''' + +from feedgen.ext.base import BaseEntryExtension +from feedgen.util import xml_elem + + +class PodcastEntryExtension(BaseEntryExtension): + '''FeedEntry extension for podcasts. + ''' + + def __init__(self): + # ITunes tags + # http://www.apple.com/itunes/podcasts/specs.html#rss + self.__itunes_author = None + self.__itunes_block = None + self.__itunes_image = None + self.__itunes_duration = None + self.__itunes_explicit = None + self.__itunes_is_closed_captioned = None + self.__itunes_order = None + self.__itunes_subtitle = None + self.__itunes_summary = None + self.__itunes_season = None + self.__itunes_episode = None + self.__itunes_title = None + self.__itunes_episode_type = None + + def extend_rss(self, entry): + '''Add additional fields to an RSS item. + + :param feed: The RSS item XML element to use. + ''' + ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' + + if self.__itunes_author: + author = xml_elem('{%s}author' % ITUNES_NS, entry) + author.text = self.__itunes_author + + if self.__itunes_block is not None: + block = xml_elem('{%s}block' % ITUNES_NS, entry) + block.text = 'yes' if self.__itunes_block else 'no' + + if self.__itunes_image: + image = xml_elem('{%s}image' % ITUNES_NS, entry) + image.attrib['href'] = self.__itunes_image + + if self.__itunes_duration: + duration = xml_elem('{%s}duration' % ITUNES_NS, entry) + duration.text = self.__itunes_duration + + if self.__itunes_explicit in ('yes', 'no', 'clean'): + explicit = xml_elem('{%s}explicit' % ITUNES_NS, entry) + explicit.text = self.__itunes_explicit + + if self.__itunes_is_closed_captioned is not None: + is_closed_captioned = xml_elem( + '{%s}isClosedCaptioned' % ITUNES_NS, entry) + if self.__itunes_is_closed_captioned: + is_closed_captioned.text = 'yes' + else: + is_closed_captioned.text = 'no' + + if self.__itunes_order is not None and self.__itunes_order >= 0: + order = xml_elem('{%s}order' % ITUNES_NS, entry) + order.text = str(self.__itunes_order) + + if self.__itunes_subtitle: + subtitle = xml_elem('{%s}subtitle' % ITUNES_NS, entry) + subtitle.text = self.__itunes_subtitle + + if self.__itunes_summary: + summary = xml_elem('{%s}summary' % ITUNES_NS, entry) + summary.text = self.__itunes_summary + + if self.__itunes_season: + season = xml_elem('{%s}season' % ITUNES_NS, entry) + season.text = str(self.__itunes_season) + + if self.__itunes_episode: + episode = xml_elem('{%s}episode' % ITUNES_NS, entry) + episode.text = str(self.__itunes_episode) + + if self.__itunes_title: + title = xml_elem('{%s}title' % ITUNES_NS, entry) + title.text = self.__itunes_title + + if self.__itunes_episode_type in ('full', 'trailer', 'bonus'): + episode_type = xml_elem('{%s}episodeType' % ITUNES_NS, entry) + episode_type.text = self.__itunes_episode_type + return entry + + def itunes_author(self, itunes_author=None): + '''Get or set the itunes:author of the podcast episode. The content of + this tag is shown in the Artist column in iTunes. If the tag is not + present, iTunes uses the contents of the <author> tag. If + <itunes:author> is not present at the feed level, iTunes will use the + contents of <managingEditor>. + + :param itunes_author: The author of the podcast. + :returns: The author of the podcast. + ''' + if itunes_author is not None: + self.__itunes_author = itunes_author + return self.__itunes_author + + def itunes_block(self, itunes_block=None): + '''Get or set the ITunes block attribute. Use this to prevent episodes + from appearing in the iTunes podcast directory. + + :param itunes_block: Block podcast episodes. + :returns: If the podcast episode is blocked. + ''' + if itunes_block is not None: + self.__itunes_block = itunes_block + return self.__itunes_block + + def itunes_image(self, itunes_image=None): + '''Get or set the image for the podcast episode. This tag specifies the + artwork for your podcast. Put the URL to the image in the href + attribute. iTunes prefers square .jpg images that are at least + 1400x1400 pixels, which is different from what is specified for the + standard RSS image tag. In order for a podcast to be eligible for an + iTunes Store feature, the accompanying image must be at least 1400x1400 + pixels. + + iTunes supports images in JPEG and PNG formats with an RGB color space + (CMYK is not supported). The URL must end in ".jpg" or ".png". If the + <itunes:image> tag is not present, iTunes will use the contents of the + RSS image tag. + + If you change your podcast’s image, also change the file’s name. iTunes + may not change the image if it checks your feed and the image URL is + the same. The server hosting your cover art image must allow HTTP head + requests for iTS to be able to automatically update your cover art. + + :param itunes_image: Image of the podcast. + :returns: Image of the podcast. + ''' + if itunes_image is not None: + if itunes_image.endswith('.jpg') or itunes_image.endswith('.png'): + self.__itunes_image = itunes_image + else: + raise ValueError('Image file must be png or jpg') + return self.__itunes_image + + def itunes_duration(self, itunes_duration=None): + '''Get or set the duration of the podcast episode. The content of this + tag is shown in the Time column in iTunes. + + The tag can be formatted HH:MM:SS, H:MM:SS, MM:SS, or M:SS (H = hours, + M = minutes, S = seconds). If an integer is provided (no colon + present), the value is assumed to be in seconds. If one colon is + present, the number to the left is assumed to be minutes, and the + number to the right is assumed to be seconds. If more than two colons + are present, the numbers farthest to the right are ignored. + + :param itunes_duration: Duration of the podcast episode. + :returns: Duration of the podcast episode. + ''' + if itunes_duration is not None: + itunes_duration = str(itunes_duration) + if len(itunes_duration.split(':')) > 3 or \ + itunes_duration.lstrip('0123456789:') != '': + raise ValueError('Invalid duration format') + self.__itunes_duration = itunes_duration + return self.__itunes_duration + + def itunes_explicit(self, itunes_explicit=None): + '''Get or the the itunes:explicit value of the podcast episode. This + tag should be used to indicate whether your podcast episode contains + explicit material. The three values for this tag are "yes", "no", and + "clean". + + If you populate this tag with "yes", an "explicit" parental advisory + graphic will appear next to your podcast artwork on the iTunes Store + and in the Name column in iTunes. If the value is "clean", the parental + advisory type is considered Clean, meaning that no explicit language or + adult content is included anywhere in the episodes, and a "clean" + graphic will appear. If the explicit tag is present and has any other + value (e.g., "no"), you see no indicator — blank is the default + advisory type. + + :param itunes_explicit: If the podcast episode contains explicit + material. + :returns: If the podcast episode contains explicit material. + ''' + if itunes_explicit is not None: + if itunes_explicit not in ('', 'yes', 'no', 'clean'): + raise ValueError('Invalid value for explicit tag') + self.__itunes_explicit = itunes_explicit + return self.__itunes_explicit + + def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None): + '''Get or set the is_closed_captioned value of the podcast episode. + This tag should be used if your podcast includes a video episode with + embedded closed captioning support. The two values for this tag are + "yes" and "no”. + + :param is_closed_captioned: If the episode has closed captioning + support. + :returns: If the episode has closed captioning support. + ''' + if itunes_is_closed_captioned is not None: + self.__itunes_is_closed_captioned = \ + itunes_is_closed_captioned in ('yes', True) + return self.__itunes_is_closed_captioned + + def itunes_order(self, itunes_order=None): + '''Get or set the itunes:order value of the podcast episode. This tag + can be used to override the default ordering of episodes on the store. + + This tag is used at an <item> level by populating with the number value + in which you would like the episode to appear on the store. For + example, if you would like an <item> to appear as the first episode in + the podcast, you would populate the <itunes:order> tag with “1”. If + conflicting order values are present in multiple episodes, the store + will use default ordering (pubDate). + + To remove the order from the episode set the order to a value below + zero. + + :param itunes_order: The order of the episode. + :returns: The order of the episode. + ''' + if itunes_order is not None: + self.__itunes_order = int(itunes_order) + return self.__itunes_order + + def itunes_subtitle(self, itunes_subtitle=None): + '''Get or set the itunes:subtitle value for the podcast episode. The + contents of this tag are shown in the Description column in iTunes. The + subtitle displays best if it is only a few words long. + + :param itunes_subtitle: Subtitle of the podcast episode. + :returns: Subtitle of the podcast episode. + ''' + if itunes_subtitle is not None: + self.__itunes_subtitle = itunes_subtitle + return self.__itunes_subtitle + + def itunes_summary(self, itunes_summary=None): + '''Get or set the itunes:summary value for the podcast episode. The + contents of this tag are shown in a separate window that appears when + the "circled i" in the Description column is clicked. It also appears + on the iTunes page for your podcast. This field can be up to 4000 + characters. If <itunes:summary> is not included, the contents of the + <description> tag are used. + + :param itunes_summary: Summary of the podcast episode. + :returns: Summary of the podcast episode. + ''' + if itunes_summary is not None: + self.__itunes_summary = itunes_summary + return self.__itunes_summary + + def itunes_season(self, itunes_season=None): + '''Get or set the itunes:season value for the podcast episode. + + :param itunes_season: Season number of the podcast epiosode. + :returns: Season number of the podcast episode. + ''' + if itunes_season is not None: + self.__itunes_season = int(itunes_season) + return self.__itunes_season + + def itunes_episode(self, itunes_episode=None): + '''Get or set the itunes:episode value for the podcast episode. + + :param itunes_season: Episode number of the podcast epiosode. + :returns: Episode number of the podcast episode. + ''' + if itunes_episode is not None: + self.__itunes_episode = int(itunes_episode) + return self.__itunes_episode + + def itunes_title(self, itunes_title=None): + '''Get or set the itunes:title value for the podcast episode. + + An episode title specific for Apple Podcasts. Don’t specify the episode + number or season number in this tag. Also, don’t repeat the title of + your show within your episode title. + + :param itunes_title: Episode title specific for Apple Podcasts + :returns: Title specific for Apple Podcast + ''' + if itunes_title is not None: + self.__itunes_title = itunes_title + return self.__itunes_title + + def itunes_episode_type(self, itunes_episode_type=None): + '''Get or set the itunes:episodeType value of the item. This tag should + be used to indicate the episode type. + The three values for this tag are "full", "trailer" and "bonus". + + If an episode is a trailer or bonus content, use this tag. + + Specify full when you are submitting the complete content of your show. + Specify trailer when you are submitting a short, promotional piece of + content that represents a preview of your current show. + Specify bonus when you are submitting extra content for your show (for + example, behind the scenes information or interviews with the cast) or + cross-promotional content for another show. + + :param itunes_episode_type: The episode type + :returns: type of the episode. + ''' + if itunes_episode_type is not None: + if itunes_episode_type not in ('full', 'trailer', 'bonus'): + raise ValueError('Invalid value for episodeType tag') + self.__itunes_episode_type = itunes_episode_type + return self.__itunes_episode_type diff --git a/feedgen/ext/syndication.py b/feedgen/ext/syndication.py new file mode 100644 index 0000000..016b144 --- /dev/null +++ b/feedgen/ext/syndication.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2015 Kenichi Sato <ksato9700@gmail.com> +# + +''' +Extends FeedGenerator to support Syndication module + +See below for details +http://web.resource.org/rss/1.0/modules/syndication/ +''' + +from feedgen.ext.base import BaseExtension +from feedgen.util import xml_elem + +SYNDICATION_NS = 'http://purl.org/rss/1.0/modules/syndication/' +PERIOD_TYPE = ('hourly', 'daily', 'weekly', 'monthly', 'yearly') + + +def _set_value(channel, name, value): + if value: + newelem = xml_elem('{%s}' % SYNDICATION_NS + name, channel) + newelem.text = value + + +class SyndicationExtension(BaseExtension): + def __init__(self): + self._update_period = None + self._update_freq = None + self._update_base = None + + def extend_ns(self): + return {'sy': SYNDICATION_NS} + + def extend_rss(self, rss_feed): + channel = rss_feed[0] + _set_value(channel, 'UpdatePeriod', self._update_period) + _set_value(channel, 'UpdateFrequency', str(self._update_freq)) + _set_value(channel, 'UpdateBase', self._update_base) + + def update_period(self, value): + if value not in PERIOD_TYPE: + raise ValueError('Invalid update period value') + self._update_period = value + return self._update_period + + def update_frequency(self, value): + if type(value) is not int or value <= 0: + raise ValueError('Invalid update frequency value') + self._update_freq = value + return self._update_freq + + def update_base(self, value): + # the value should be in W3CDTF format + self._update_base = value + return self._update_base + + +class SyndicationEntryExtension(BaseExtension): + pass diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py new file mode 100644 index 0000000..5548a81 --- /dev/null +++ b/feedgen/ext/torrent.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.torrent + ~~~~~~~~~~~~~~~~~~~ + + Extends the FeedGenerator to produce torrent feeds. + + :copyright: 2016, Raspbeguy <raspbeguy@hashtagueule.fr> + + :license: FreeBSD and LGPL, see license.* for more details. +''' + +from feedgen.ext.base import BaseEntryExtension, BaseExtension +from feedgen.util import xml_elem + +TORRENT_NS = 'http://xmlns.ezrss.it/0.1/dtd/' + + +class TorrentExtension(BaseExtension): + '''FeedGenerator extension for torrent feeds. + ''' + def extend_ns(self): + return {'torrent': TORRENT_NS} + + +class TorrentEntryExtension(BaseEntryExtension): + '''FeedEntry extension for torrent feeds + ''' + def __init__(self): + self.__torrent_filename = None + self.__torrent_infohash = None + self.__torrent_contentlength = None + self.__torrent_seeds = None + self.__torrent_peers = None + self.__torrent_verified = None + + def extend_rss(self, entry): + '''Add additional fields to an RSS item. + + :param feed: The RSS item XML element to use. + ''' + if self.__torrent_filename: + filename = xml_elem('{%s}filename' % TORRENT_NS, entry) + filename.text = self.__torrent_filename + + if self.__torrent_contentlength: + contentlength = xml_elem('{%s}contentlength' % TORRENT_NS, entry) + contentlength.text = self.__torrent_contentlength + + if self.__torrent_infohash: + infohash = xml_elem('{%s}infohash' % TORRENT_NS, entry) + infohash.text = self.__torrent_infohash + magnet = xml_elem('{%s}magneturi' % TORRENT_NS, entry) + magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_infohash + + if self.__torrent_seeds: + seeds = xml_elem('{%s}seed' % TORRENT_NS, entry) + seeds.text = self.__torrent_seeds + + if self.__torrent_peers: + peers = xml_elem('{%s}peers' % TORRENT_NS, entry) + peers.text = self.__torrent_peers + + if self.__torrent_verified: + verified = xml_elem('{%s}verified' % TORRENT_NS, entry) + verified.text = self.__torrent_verified + + def filename(self, torrent_filename=None): + '''Get or set the name of the torrent file. + + :param torrent_filename: The name of the torrent file. + :returns: The name of the torrent file. + ''' + if torrent_filename is not None: + self.__torrent_filename = torrent_filename + return self.__torrent_filename + + def infohash(self, torrent_infohash=None): + '''Get or set the hash of the target file. + + :param torrent_infohash: The target file hash. + :returns: The target hash file. + ''' + if torrent_infohash is not None: + self.__torrent_infohash = torrent_infohash + return self.__torrent_infohash + + def contentlength(self, torrent_contentlength=None): + '''Get or set the size of the target file. + + :param torrent_contentlength: The target file size. + :returns: The target file size. + ''' + if torrent_contentlength is not None: + self.__torrent_contentlength = torrent_contentlength + return self.__torrent_contentlength + + def seeds(self, torrent_seeds=None): + '''Get or set the number of seeds. + + :param torrent_seeds: The seeds number. + :returns: The seeds number. + ''' + if torrent_seeds is not None: + self.__torrent_seeds = torrent_seeds + return self.__torrent_seeds + + def peers(self, torrent_peers=None): + '''Get or set the number od peers + + :param torrent_infohash: The peers number. + :returns: The peers number. + ''' + if torrent_peers is not None: + self.__torrent_peers = torrent_peers + return self.__torrent_peers + + def verified(self, torrent_verified=None): + '''Get or set the number of verified peers. + + :param torrent_infohash: The verified peers number. + :returns: The verified peers number. + ''' + if torrent_verified is not None: + self.__torrent_verified = torrent_verified + return self.__torrent_verified |