#!/usr/bin/env python3 """Build Soren's private podcast discovery feed. The Obsidian dashboard carries machine-readable candidate payloads in HTML comments followed by an ordinary Markdown checkbox. Checked candidates are added to the RSS feed. Podcast items retain their publisher-hosted enclosure; YouTube items are downloaded and converted to local MP3 files. """ from __future__ import annotations import argparse import base64 import email.utils import html import json import os from pathlib import Path import re import subprocess import tempfile from datetime import datetime, timezone from urllib.request import Request, urlopen from xml.etree import ElementTree as ET ROOT = Path("/Users/soren/Podcasts") MEDIA_DIR = ROOT / "discoveries" STATE_PATH = MEDIA_DIR / "state.json" FEED_PATH = ROOT / "discoveries.xml" BASE_URL = "https://mini.taila1ac2d.ts.net" FEED_ART_URL = f"{BASE_URL}/discoveries/cover.png" ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd" SY_NS = "http://purl.org/rss/1.0/modules/syndication/" CANDIDATE_RE = re.compile( r"(?P)" r"(?P\s*)- \[(?P[ xX])\] Add to discoveries feed" r"(?: — (?:Added to feed|Already in feed|ERROR:[^\r\n]*))?" ) def now_rfc2822() -> str: return email.utils.format_datetime(datetime.now(timezone.utc)) def load_state() -> dict: if not STATE_PATH.exists(): return {"version": 1, "items": []} return json.loads(STATE_PATH.read_text(encoding="utf-8")) def atomic_text(path: Path, value: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(value) handle.flush() os.fsync(handle.fileno()) os.replace(tmp_name, path) except Exception: Path(tmp_name).unlink(missing_ok=True) raise def save_state(state: dict) -> None: atomic_text(STATE_PATH, json.dumps(state, ensure_ascii=False, indent=2) + "\n") def item_exists(state: dict, guid: str) -> bool: return any(item.get("guid") == guid for item in state["items"]) def normalize_pub_date(value: str | None) -> str: if not value: return now_rfc2822() if re.fullmatch(r"\d{8}", value): parsed = datetime.strptime(value, "%Y%m%d").replace(tzinfo=timezone.utc) return email.utils.format_datetime(parsed) try: parsed = email.utils.parsedate_to_datetime(value) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return email.utils.format_datetime(parsed) except (TypeError, ValueError): return value def add_rss_item(state: dict, data: dict) -> bool: required = ("guid", "title", "enclosure_url") missing = [key for key in required if not data.get(key)] if missing: raise ValueError(f"RSS candidate is missing: {', '.join(missing)}") guid = f"rss:{data['guid']}" if item_exists(state, guid): return False state["items"].append( { "guid": guid, "source": "rss", "show": data.get("show", "Recommended podcast"), "title": data["title"], "link": data.get("link", data["enclosure_url"]), "description": data.get("description", ""), "pub_date": normalize_pub_date(data.get("pub_date")), "added": now_rfc2822(), "enclosure_url": data["enclosure_url"], "enclosure_type": data.get("enclosure_type", "audio/mpeg"), "length": int(data.get("length") or 0), "duration": data.get("duration", ""), "image": data.get("image", ""), "source_feed": data.get("source_feed", ""), } ) return True def child_text(item: ET.Element, name: str) -> str: node = item.find(name) return (node.text or "").strip() if node is not None else "" def import_matching_feed(state: dict, feed_url: str, pattern: str, since: str | None) -> int: request = Request(feed_url, headers={"User-Agent": "SorenPodcastRecommendations/1.0"}) with urlopen(request, timeout=30) as response: root = ET.fromstring(response.read()) regex = re.compile(pattern, re.IGNORECASE) cutoff = datetime.fromisoformat(since).replace(tzinfo=timezone.utc) if since else None channel = root.find("channel") or root show = child_text(channel, "title") show_image_node = channel.find(f"{{{ITUNES_NS}}}image") show_image = show_image_node.get("href", "") if show_image_node is not None else "" if not show_image: show_image = child_text(channel, "image/url") added = 0 for item in root.findall(".//item"): title = child_text(item, "title") description = child_text(item, "description") if not regex.search(f"{title}\n{description}"): continue pub_date = child_text(item, "pubDate") if cutoff and pub_date: published = email.utils.parsedate_to_datetime(pub_date) if published.tzinfo is None: published = published.replace(tzinfo=timezone.utc) if published < cutoff: continue enclosure = item.find("enclosure") if enclosure is None or not enclosure.get("url"): continue guid = child_text(item, "guid") or child_text(item, "link") or enclosure.get("url", "") image = item.find(f"{{{ITUNES_NS}}}image") duration = child_text(item, f"{{{ITUNES_NS}}}duration") data = { "guid": guid, "title": title, "show": show, "link": child_text(item, "link") or enclosure.get("url"), "description": description, "pub_date": pub_date, "enclosure_url": enclosure.get("url"), "enclosure_type": enclosure.get("type", "audio/mpeg"), "length": enclosure.get("length", "0"), "duration": duration, "image": image.get("href", "") if image is not None else show_image, "source_feed": feed_url, } existing = next((record for record in state["items"] if record.get("guid") == f"rss:{guid}"), None) if existing is not None: if not existing.get("image") and data["image"]: existing["image"] = data["image"] if not existing.get("source_feed"): existing["source_feed"] = feed_url continue if add_rss_item(state, data): added += 1 return added def youtube_metadata(url: str) -> dict: result = subprocess.run( ["/opt/homebrew/bin/yt-dlp", "--dump-single-json", "--no-playlist", url], check=True, capture_output=True, text=True, ) return json.loads(result.stdout) def add_youtube_item(state: dict, data: dict) -> bool: url = data.get("url") or data.get("link") if not url: raise ValueError("YouTube candidate has no URL") metadata = youtube_metadata(url) video_id = metadata["id"] guid = f"youtube:{video_id}" if item_exists(state, guid): return False MEDIA_DIR.mkdir(parents=True, exist_ok=True) output_template = str(MEDIA_DIR / f"youtube_{video_id}.%(ext)s") download_command = [ "/opt/homebrew/bin/yt-dlp", "--no-playlist", "-f", "140/bestaudio", "-x", "--audio-format", "mp3", "--audio-quality", "0", "-o", output_template, url, ] try: subprocess.run(download_command, check=True) except subprocess.CalledProcessError: # Some public videos now require a different token-free player client. # Keep the ordinary path first because the embedded client is available # only when the publisher permits playback outside youtube.com. subprocess.run( download_command[:2] + ["--extractor-args", "youtube:player_client=web_embedded"] + download_command[2:], check=True, ) media_path = MEDIA_DIR / f"youtube_{video_id}.mp3" if not media_path.exists(): raise RuntimeError(f"Expected output was not created: {media_path}") duration_seconds = int(metadata.get("duration") or 0) state["items"].append( { "guid": guid, "source": "youtube", "show": metadata.get("channel") or metadata.get("uploader") or "YouTube", "title": metadata.get("title") or video_id, "link": metadata.get("webpage_url") or url, "description": metadata.get("description") or "", "pub_date": normalize_pub_date(metadata.get("upload_date")), "added": now_rfc2822(), "enclosure_url": f"{BASE_URL}/discoveries/{media_path.name}", "enclosure_type": "audio/mpeg", "length": media_path.stat().st_size, "duration": str(duration_seconds), "image": metadata.get("thumbnail") or "", "source_feed": metadata.get("channel_url") or metadata.get("uploader_url") or "", } ) return True def rebuild_feed(state: dict) -> None: ET.register_namespace("itunes", ITUNES_NS) ET.register_namespace("sy", SY_NS) rss = ET.Element("rss", {"version": "2.0"}) channel = ET.SubElement(rss, "channel") for tag, value in ( ("title", "Soren's Discoveries"), ("link", f"{BASE_URL}/discoveries.xml"), ("description", "Unfamiliar podcasts and videos discovered outside Soren's current subscriptions."), ("language", "en-us"), ("lastBuildDate", now_rfc2822()), ("ttl", "5"), ): ET.SubElement(channel, tag).text = value ET.SubElement(channel, f"{{{SY_NS}}}updatePeriod").text = "hourly" ET.SubElement(channel, f"{{{SY_NS}}}updateFrequency").text = "1" cover = ET.SubElement(channel, "image") ET.SubElement(cover, "url").text = FEED_ART_URL ET.SubElement(cover, "title").text = "Soren's Discoveries" ET.SubElement(cover, "link").text = f"{BASE_URL}/discoveries.xml" ET.SubElement(channel, f"{{{ITUNES_NS}}}author").text = "Soren Dayton" ET.SubElement(channel, f"{{{ITUNES_NS}}}block").text = "yes" ET.SubElement(channel, f"{{{ITUNES_NS}}}explicit").text = "false" ET.SubElement(channel, f"{{{ITUNES_NS}}}image", {"href": FEED_ART_URL}) def added_key(item: dict) -> datetime: return email.utils.parsedate_to_datetime(item["added"]) for record in sorted(state["items"], key=added_key, reverse=True): item = ET.SubElement(channel, "item") ET.SubElement(item, "guid", {"isPermaLink": "false"}).text = record["guid"] show = record.get("show", "") display_title = f"{record['title']} — {show}" if show else record["title"] ET.SubElement(item, "title").text = display_title ET.SubElement(item, "link").text = record["link"] source_label = "YouTube channel" if record.get("source") == "youtube" else "Source podcast" source_line = f'

{source_label}: {html.escape(show)}

' original_date = html.escape(record.get("pub_date", "")) date_line = f"

Originally published: {original_date}

" if original_date else "" ET.SubElement(item, "description").text = source_line + date_line + record.get("description", "") # This is a curated feed, so an item's release date is when it entered # Discoveries. The publisher's date remains visible in the description. ET.SubElement(item, "pubDate").text = record["added"] if record.get("source_feed"): ET.SubElement(item, "source", {"url": record["source_feed"]}).text = show ET.SubElement( item, "enclosure", { "url": record["enclosure_url"], "length": str(record.get("length", 0)), "type": record.get("enclosure_type", "audio/mpeg"), }, ) ET.SubElement(item, f"{{{ITUNES_NS}}}author").text = record.get("show", "") if record.get("duration"): ET.SubElement(item, f"{{{ITUNES_NS}}}duration").text = record["duration"] ET.SubElement(item, f"{{{ITUNES_NS}}}image", {"href": record.get("image") or FEED_ART_URL}) ET.indent(rss, space=" ") xml = '\n' + ET.tostring( rss, encoding="unicode", xml_declaration=False ) atomic_text(FEED_PATH, xml + "\n") def decode_payload(value: str) -> dict: padding = "=" * (-len(value) % 4) return json.loads(base64.urlsafe_b64decode(value + padding).decode("utf-8")) def sync_dashboard(path: Path, update_dashboard: bool = True) -> tuple[int, int]: text = path.read_text(encoding="utf-8") state = load_state() added = 0 failed = 0 def process(match: re.Match) -> str: nonlocal added, failed if match.group("checked") == " ": return match.group(0) try: data = decode_payload(match.group("payload")) changed = add_youtube_item(state, data) if data.get("kind") == "youtube" else add_rss_item(state, data) if changed: added += 1 suffix = " — Added to feed" if changed else " — Already in feed" return f'{match.group("comment")}{match.group("middle")}- [x] Add to discoveries feed{suffix}' except Exception as exc: failed += 1 safe_error = html.escape(str(exc).replace("\n", " ")[:240]) return f'{match.group("comment")}{match.group("middle")}- [x] Add to discoveries feed — ERROR: {safe_error}' updated = CANDIDATE_RE.sub(process, text) save_state(state) rebuild_feed(state) if update_dashboard and updated != text: atomic_text(path, updated) return added, failed def main() -> int: parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="command", required=True) sync = sub.add_parser("sync-dashboard") sync.add_argument("dashboard", type=Path) sync.add_argument( "--read-only-dashboard", action="store_true", help="Process checked candidates without writing status text back to the vault.", ) importer = sub.add_parser("import-rss") importer.add_argument("feed_url") importer.add_argument("--match", required=True) importer.add_argument("--since", help="Only import items on or after YYYY-MM-DD") sub.add_parser("rebuild") args = parser.parse_args() MEDIA_DIR.mkdir(parents=True, exist_ok=True) if args.command == "sync-dashboard": added, failed = sync_dashboard(args.dashboard, update_dashboard=not args.read_only_dashboard) print(f"Added {added}; failed {failed}; feed: {BASE_URL}/discoveries.xml") return 1 if failed else 0 if args.command == "import-rss": state = load_state() added = import_matching_feed(state, args.feed_url, args.match, args.since) save_state(state) rebuild_feed(state) print(f"Imported {added}; feed: {BASE_URL}/discoveries.xml") return 0 state = load_state() save_state(state) rebuild_feed(state) print(f"Rebuilt {BASE_URL}/discoveries.xml with {len(state['items'])} items") return 0 if __name__ == "__main__": raise SystemExit(main())