diff --git a/podcast/.gitignore b/podcast/.gitignore new file mode 100644 index 0000000..67c51fe --- /dev/null +++ b/podcast/.gitignore @@ -0,0 +1,2 @@ +*.xml +*.json diff --git a/podcast/create-feed.py b/podcast/create-feed.py new file mode 100644 index 0000000..a7635b5 --- /dev/null +++ b/podcast/create-feed.py @@ -0,0 +1,244 @@ +""" +this script creates a feed.xml file based on JSON data of a podcast from apple podcast (itunes) API +""" + + +import json +from pathlib import Path +from datetime import datetime, timezone +from email.utils import format_datetime +from urllib.parse import urlparse +import xml.etree.ElementTree as ET +from xml.dom import minidom + + +# sample curl for season episodes details: + # "id": "1573565122-8", + # "type": "podcast-seasons", + +# sample curl for channel details + # "type": "podcasts", + # "href": "/v1/catalog/us/podcasts/1573565122?l=en-US", + + +json_path = Path('season-7-episoded-formatted.json') +channel_path = Path('channel-details.json') +output_path = Path('Bibliocast-custom.xml') + +obj = json.loads(json_path.read_text()) +seasons = obj.get('data', []) + +channel_obj = json.loads(channel_path.read_text()) if channel_path.exists() else {} +channel_data_list = channel_obj.get('data') or [] +channel_attrs = channel_data_list[0].get('attributes', {}) if channel_data_list else {} + +episodes = [] +for season in seasons: + episodes.extend(season.get('relationships', {}).get('episodes', {}).get('data', [])) + +# Sort by releaseDateTime descending (latest first) +def parse_dt(value): + if not value: + return None + # ISO 8601 with Z + if value.endswith('Z'): + value = value.replace('Z', '+00:00') + try: + return datetime.fromisoformat(value) + except ValueError: + return None + +episodes_sorted = sorted( + episodes, + key=lambda ep: parse_dt(ep.get('attributes', {}).get('releaseDateTime')) or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, +) + +# Channel-level data (from channel details, fallback to episode data) +first_attr = episodes_sorted[0]['attributes'] if episodes_sorted else {} +channel_title = channel_attrs.get('name') or 'Bibliocast' +artist_name = channel_attrs.get('artistName') or first_attr.get('artistName') +artwork_url = (channel_attrs.get('artwork') or {}).get('url') or (first_attr.get('artwork') or {}).get('url') +website_url = channel_attrs.get('websiteUrl') or first_attr.get('websiteUrl') +base_link = None +if website_url: + parsed = urlparse(website_url) + base_link = f"{parsed.scheme}://{parsed.netloc}/" if parsed.scheme and parsed.netloc else None + +channel_description = None +desc = channel_attrs.get('description') +if isinstance(desc, dict): + channel_description = desc.get('standard') or desc.get('short') +elif isinstance(desc, str): + channel_description = desc +if not channel_description: + channel_description = 'Custom feed generated from Bibliocast episode data.' + +copyright_text = channel_attrs.get('copyright') or first_attr.get('copyright') + +# genre +genres = set(channel_attrs.get('genreNames') or []) +explicit_values = set() +for ep in episodes_sorted: + attrs = ep.get('attributes', {}) + for g in (attrs.get('genreNames') or []): + if g: + genres.add(g) + rating = (attrs.get('contentRating') or '').strip().lower() + if rating: + explicit_values.add(rating) + +channel_explicit = None +channel_rating = channel_attrs.get('contentRating') +if channel_rating: + channel_rating = channel_rating.strip().lower() + if channel_rating == 'explicit': + channel_explicit = 'yes' + elif channel_rating == 'clean': + channel_explicit = 'clean' + elif channel_rating == 'notexplicit': + channel_explicit = 'no' + +if not channel_explicit: + if 'explicit' in explicit_values: + channel_explicit = 'yes' + elif 'clean' in explicit_values: + channel_explicit = 'clean' + +channel_release_dt = parse_dt(channel_attrs.get('releaseDateTime')) + +# Namespaces +itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' +ET.register_namespace('itunes', itunes_ns) + +rss = ET.Element('rss', {'version': '2.0'}) +channel = ET.SubElement(rss, 'channel') + +# Required/standard channel elements +ET.SubElement(channel, 'title').text = channel_title +if website_url: + ET.SubElement(channel, 'link').text = website_url +elif base_link: + ET.SubElement(channel, 'link').text = base_link +if channel_description: + ET.SubElement(channel, 'description').text = channel_description +if copyright_text: + ET.SubElement(channel, 'copyright').text = copyright_text +if channel_release_dt: + ET.SubElement(channel, 'lastBuildDate').text = format_datetime(channel_release_dt) +elif episodes_sorted: + latest_dt = parse_dt(episodes_sorted[0].get('attributes', {}).get('releaseDateTime')) + if latest_dt: + ET.SubElement(channel, 'lastBuildDate').text = format_datetime(latest_dt) + +# iTunes channel elements +if artist_name: + ET.SubElement(channel, f'{{{itunes_ns}}}author').text = artist_name +if channel_explicit: + ET.SubElement(channel, f'{{{itunes_ns}}}explicit').text = channel_explicit +if artwork_url: + ET.SubElement(channel, f'{{{itunes_ns}}}image', {'href': artwork_url}) + +# iTunes category (use first genre, if any) +if genres: + for genre in sorted(genres): + ET.SubElement(channel, f'{{{itunes_ns}}}category', {'text': genre}) + +# Helpers + +def duration_to_itunes(ms): + if ms is None: + return None + try: + total_seconds = int(round(ms / 1000)) + except Exception: + return None + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + if hours > 0: + return f"{hours}:{minutes:02d}:{seconds:02d}" + return f"{minutes}:{seconds:02d}" + + +def rating_to_itunes(value): + if not value: + return None + value = value.strip().lower() + if value == 'explicit': + return 'yes' + if value == 'clean': + return 'clean' + if value == 'notexplicit': + return 'no' + return None + +for ep in episodes_sorted: + attrs = ep.get('attributes', {}) + item = ET.SubElement(channel, 'item') + + title = attrs.get('itunesTitle') or attrs.get('name') + if title: + ET.SubElement(item, 'title').text = title + ET.SubElement(item, f'{{{itunes_ns}}}title').text = title + + link = attrs.get('websiteUrl') or attrs.get('url') + if link: + ET.SubElement(item, 'link').text = link + + guid = attrs.get('guid') + if guid: + guid_el = ET.SubElement(item, 'guid', {'isPermaLink': 'false'}) + guid_el.text = guid + + # Description + desc = (attrs.get('description') or {}).get('standard') or (attrs.get('description') or {}).get('short') + if desc: + ET.SubElement(item, 'description').text = desc + ET.SubElement(item, f'{{{itunes_ns}}}summary').text = desc + short_desc = (attrs.get('description') or {}).get('short') + if short_desc: + ET.SubElement(item, f'{{{itunes_ns}}}subtitle').text = short_desc + + # Publication date + release = attrs.get('releaseDateTime') + release_dt = parse_dt(release) + if release_dt: + ET.SubElement(item, 'pubDate').text = format_datetime(release_dt) + + # Enclosure + asset_url = attrs.get('assetUrl') + if asset_url: + ET.SubElement(item, 'enclosure', {'url': asset_url, 'type': 'audio/mpeg'}) + + # iTunes fields + if artist_name: + ET.SubElement(item, f'{{{itunes_ns}}}author').text = artist_name + + season_number = attrs.get('seasonNumber') + if season_number is not None: + ET.SubElement(item, f'{{{itunes_ns}}}season').text = str(season_number) + + episode_number = attrs.get('episodeNumber') + if episode_number is not None: + ET.SubElement(item, f'{{{itunes_ns}}}episode').text = str(episode_number) + + duration = duration_to_itunes(attrs.get('durationInMilliseconds')) + if duration: + ET.SubElement(item, f'{{{itunes_ns}}}duration').text = duration + + rating = rating_to_itunes(attrs.get('contentRating')) + if rating: + ET.SubElement(item, f'{{{itunes_ns}}}explicit').text = rating + + art = (attrs.get('artwork') or {}).get('url') + if art: + ET.SubElement(item, f'{{{itunes_ns}}}image', {'href': art}) + +# Pretty print +rough_string = ET.tostring(rss, encoding='utf-8') +parsed = minidom.parseString(rough_string) +pretty_xml = parsed.toprettyxml(indent=' ', encoding='utf-8') + +output_path.write_bytes(pretty_xml) +print(f'Wrote {output_path} with {len(episodes_sorted)} episodes')