Podcasts for Shockz OpenSwim

Soil:
By: Alfie Chadwick Date: June 27, 2026
Seeds:

I absolutely love my swimming headphones. There’s just something about being able to get in the pool with a podcast on and zone out for an hour – chef’s kiss.

But they do have a bit of a setup problem, because they don’t have any nice syncing features, which means any audio you want to put on them needs to be in MP3 format. I can handle this for my MP3 music, because I have albums on my computer, but for podcasts I still use an app.

I found out Pocket Casts lets you export an OPML file of all your podcasts, so I made a short script that uses that file to create a super simple CLI for downloading podcasts to my Shockz, so I can just plug them in and download the podcasts directly to them.

#!/usr/bin/env python3
import xml.etree.ElementTree as ET
import feedparser
import requests
from time import strftime
import questionary

# Configuration
OPML_FILE = "/root/feeds/pods.opml"


def parse_opml(file):
    feeds = {}
    tree = ET.parse(file)
    for outline in tree.findall(".//outline"):
        url = outline.attrib.get("xmlUrl")
        if url:
            title = outline.attrib.get("title", outline.attrib.get("text", url))
            feeds[title] = url
    return feeds


def sanitize_filename(name):
    return "".join(c for c in name if c.isalnum() or c in " ._-").rstrip()


def download(url, outpath):
    try:
        r = requests.get(
            url,
            headers={
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
            },
            stream=True,
            timeout=30,
        )
        r.raise_for_status()

        with open(outpath, "wb") as f:
            for chunk in r.iter_content(1024 * 128):
                f.write(chunk)

        return {"success": True}
    except requests.RequestException as e:
        print("Failed:", url, str(e))
        return {"success": False, "expected_size": 0, "actual_size": 0}


def get_ep_url(entry):
    if entry["links"]:
        audio_url = [x for x in entry["links"] if "audio" in x.get("type", "")][0].get(
            "href"
        )
        return audio_url


def main():
    while True:
        feeds = parse_opml(OPML_FILE)
        key = questionary.select("Which podcast?", choices=list(feeds)).ask()
        if key is None:
            break

        feed_url = feeds[key]
        parsed = feedparser.parse(feed_url).entries
        ep_names = [
            f"{strftime('%Y-%m-%d', x['published_parsed'])} - {x['title']}"
            for x in parsed
        ]
        episode_names = questionary.checkbox("Select episodes", ep_names).ask()
        if not episode_names:
            continue

        for ep in episode_names:
            file_name = sanitize_filename(ep)
            url = get_ep_url(parsed[ep_names.index(ep)])
            download(url, file_name)

        exit_choice = questionary.confirm("Exit?").ask()
        if exit_choice:
            break


if __name__ == "__main__":
    main()