Closing the Loop: How I Made My Coros Pace 4 Watch Talk to My Podcast App
I hate boring, manual work. I hate duplication even more than boring work. Luckily, I have the skills to eliminate both.
I listen to podcasts on long runs, never during workouts. It is the one chance I have to chip away at the podcast backlog, because the weekly commute is not enough. But carrying a phone for that created its own problem. Recently I got a new watch. The Coros Pace 4 is a fantastic running watch that plays audio files. It doesn’t integrate with any streaming service but takes raw MP3 files instead, early 2000s MP3-player style. Download a few podcast episodes manually, copy them onto the watch, and go for the long run. Three issues: the raw episode files are not human-readable, which makes it challenging to choose an episode on the watch interface—think 422225950-44100-2-bf5e92b380232.mp3. Second, after the run I would have to open the podcast app on my phone and mark the episode as played. Lastly, I’d have to delete the episode on the watch itself (remembering the awkward filename). Miss one step, and I am re-listening to last Tuesday's episode on Sunday’s long run.
Iterate to Improve
The first problem was solved with a quick web search. I could use podcast-dl to download episodes from a given podcast feed. It generates nice filenames. Manually drag those to the watch. One issue down, two to go: manually marking them played in my podcast app after every run. The kind of workflow that works exactly twice before you start resenting it. My tried and trusted Pocket Casts app doesn't expose an API, which meant whatever automation I would think of wouldn’t work with Pocket Casts.
Enter AntennaPod + gPodder Sync
My hypothesis was this: if I could find a podcast app that offers an API, I could come up with a way to synchronize podcast episodes to my Coros watch. A few moments of research later, I found out about the gPodder sync protocol. That protocol is simple: subscriptions are lists of feed URLs, and the played state is a list of episode actions with positions. Self-host a compatible server (I went with opodsync in Docker), and you have an API layer that both your phone and your scripts can talk to. From there it was another few search results to find an app that speaks that protocol: enter AntennaPod.
Why AntennaPod? It is open-source and offers variable playback speed per podcast, silence skip, and start/end trim. Feature parity with Pocket Casts where it matters to me. After testing AntennaPod on my daily commute for a few days, I could confirm that the switch wouldn’t create any negative effects.
Here is the result of fiddling in an editor for a couple of hours.
The Architecture (for the nerds)
opodsync API Coros Watch
┌──────────────┐ ┌─────────────┐
│ subscriptions│ | │ /MUSIC/ │
│ played state │ │ │ 01 ep.mp3 │
└──────────────┘ │ | 02 ep.mp3 |
▼ └──────┬──────┘
sync_to_coros.py │
- fetch favorite feeds │ run, listen,
- download via podcast-dl │ delete on watch
- skip played episodes │
- number & copy to watch ▼
mark_played.py
- diff manifest vs. watch
- POST played → opodsync
- AntennaPod syncs
opodsync on Docker (5 minutes, really)
I haven't researched all other options for running a gPodder-sync-compliant server. This is what works for me, and it took 5 minutes to set up.
services:
opodsync:
image: ganeshlab/opodsync:latest
container_name: opodsync
restart: unless-stopped
ports:
- "8443:8080"
volumes:
- opodsync_data:/var/www/server/data
volumes:
opodsync_data:
You can run this on your home server, with the downside of AntennaPod only being able to sync when you are at home (unless you expose your home network to the internet). Or you run this on a VPS for instant sync access. Be aware that the above configuration exposes opodsync on an insecure HTTP port; if this bothers you, then you need to add in Caddy and an SSL certificate. I left this as an exercise to the reader. When you first connect to opodsync (in my case http://ip.of.vps:8443/) it asks you to create an account and set a password. Make note of those account details; you will need them later in your .env file.
The Scripts
**sync_to_coros.py** – the downloader. Fetches the subscriptions from the opodsync API, filters favorite feeds (configured in .env), downloads recent episodes with podcast-dl, skips anything played or already on the watch, and copies what fits within a storage cap. Files get numbered prefixes (01, 02, …) sorted by publish date, so I can navigate them on the watch's bare-bones interface without squinting at truncated titles.
mark_played.py – the closer. After a run, plug in the watch and delete the files you have listened to on the run. The script diffs the manifest against what is on the watch. Anything you deleted post-run gets POSTed to opodsync as played. AntennaPod picks it up on the next sync. Loop closed.
The key design decision: the manifest tracks episodes by URL, not filename. Filenames change between runs (because the numbering shifts as episodes come and go), but episode URLs are stable identifiers. This also means mark_played.py doesn't care about the numbering scheme at all—it just checks which URLs from the manifest are still present on the watch.
Iteration Notes
The system went through a few iterations worth noting:
v1: One feed at a time. The first version took a feed URL as a command-line argument. I’d run it once per podcast. This worked but meant maintaining a shell script that called it N times, and there was no cross-feed prioritization.
v2: API-driven, zero arguments. The current version fetches subscriptions directly from opodsync. No arguments needed. But syncing all 30+ subscriptions to a watch with 4 GB of space didn't make sense, so I added a FAVORITE_FEEDS filter in .env -- the same feeds I have set to auto-download in AntennaPod. You could use this differently by making the FAVORITE_FEEDS contain the podcasts you don’t regularly listen to. Way to spice up the long run!
The “played” mystery. The hardest part of the whole project wasn't downloading or copying—it was convincing AntennaPod that an episode had been played. The gPodder API accepts episode actions with action: "play", and I assumed that was enough. It wasn't. I'd POST the action, opodsync would accept it, and AntennaPod would ignore it on the next sync. No error, no feedback; the episode simply wasn’t marked as played.
It turns out AntennaPod doesn't look at whether a play action exists—it checks whether position equals total. If you don't set both, or if they're zero, the action is treated as “in progress” rather than “finished.” The fix:
{
"action": "play",
"position": meta.get("duration", 0), # must equal total
"total": meta.get("duration", 0),
}
This is why the manifest stores each episode's duration (parsed from the metadata during download). Without it, the loop never closes. The gPodder protocol spec doesn't make this obvious—I discovered it by watching AntennaPod not react and working backwards.
v3: Select episodes based on the length of the planned long run. This is an iteration planned for the future. It probably needs scheduling the long run in the Coros app and me figuring out how to get that information from the watch. Right now the storage cap works well.
Does It Work?
I’ve done a couple of runs now with this setup, and my final verdict is: it works, and it frees me from bringing my phone with me on long runs. Would I wish for a more automatic sync over WiFi without connecting the watch before and after runs? Yes. But I knew ahead of time that Coros doesn’t support playback services. These scripts were simple enough to write, and they proved to be useful to me.
This is how it looks in real life:
🔍 Fetching subscriptions from opodsync...
33 subscriptions found
Filtered to 4 favorite feeds
🔍 Fetching played episodes from opodsync...
39 played episode URLs known
📡 Checking: https://anchor.fm/s/10a762af4/podcast/rss
✅ Already played: 1:09:56 mit drei Jobs neben dem Training — Lara Kiene über ihren späten Durchbruch | Auslaufen Podcast Dauerlauf
✅ Already played: Diamond League Auftakt in Shanghai — knackt Mohamed Abdilaahi den letzten Baumann-Rekord? | Auslaufen Podcast
✅ Already played: Felix' Trail-Survival-Mode in Innsbruck — Blanka Dörfel schockt alle in Celle | Auslaufen Podcast
✅ Already played: Ida-Sophie Hegemann, oder: Auslaufen goes Trail
✅ Already played: Mo Abdilaahi 7:25 – Diamond League Shanghai, EM-Marathon-Team & Preview Rehlingen + Brüssel
📡 Checking: https://feeds.acast.com/public/shows/61bb951645cc6900145d924a
🎯 Candidate: Episode 247 | Simon Freeman and Mario Fraioli on When to Push Hard and When to Pull Back (65 MB)
🎯 Candidate: Episode 248 | Devin Kelly on His New Novel, Exploring His Obsessions, and Committing to a Daily Practice (74 MB)
🎯 Candidate: Episode 249 | Simon Freeman and Mario Fraioli on Like the Wind U.S., Fresh Starts, and Cultural Differences in Running (84 MB)
🎯 Candidate: Episode 250 | Raziq Rauf on Running Crews, Culture, and Community (70 MB)
🎯 Candidate: Episode 251 | Matt Taylor on Brand Building, Storytelling, and Creative Expression (74 MB)
📡 Checking: https://rss.art19.com/finding-mastery
🎯 Candidate: 5 Questions to Unlock Your Inner Potential | Dr. Mike Gervais - AMA Vol.29 (64 MB)
✅ Already played: The Psychology Of Feeling Loved | Dr Sonja Lyubomirsky
🎯 Candidate: The Psychology Of Money | Tom Bilyeu (72 MB)
🎯 Candidate: The Psychology of Being a Super Communicator | Charles Duhigg (56 MB)
🎯 Candidate: The Psychology of Performance Under Pressure |Andrew Whitworth (89 MB)
📡 Checking: https://anchor.fm/s/10a7f6894/podcast/rss
🎯 Candidate: Better Together: Staffel-DM & Reunion (61 MB)
🎯 Candidate: Gabriela Gajanová - Growing together & following your heart to new records (64 MB)
✅ Already played: Predictions We Might Regret Later - Season 26 let‘s go!
🎯 Candidate: Season Opener: Back on Track & Recovery Mode (53 MB)
🎯 Candidate: Sophie Weissenberg - Focus on What’s Next (70 MB)
⚠️ Storage cap reached (500 MB), skipping: Sophie Weissenberg - Focus on What’s Next
⚠️ Storage cap reached (500 MB), skipping: The Psychology Of Money | Tom Bilyeu
⚠️ Storage cap reached (500 MB), skipping: Episode 250 | Raziq Rauf on Running Crews, Culture, and Community
⚠️ Storage cap reached (500 MB), skipping: Episode 249 | Simon Freeman and Mario Fraioli on Like the Wind U.S., Fresh Starts, and Cultural Differences in Running
⚠️ Storage cap reached (500 MB), skipping: Episode 248 | Devin Kelly on His New Novel, Exploring His Obsessions, and Committing to a Daily Practice
⚠️ Storage cap reached (500 MB), skipping: Episode 247 | Simon Freeman and Mario Fraioli on When to Push Hard and When to Pull Back
📋 Copying 7 episode(s) to Coros (460 MB / 500 MB used):
✅ The Psychology of Performance Under Pressure |Andrew Whitworth
✅ Season Opener: Back on Track & Recovery Mode
✅ 5 Questions to Unlock Your Inner Potential | Dr. Mike Gervais - AMA Vol.29
✅ Better Together: Staffel-DM & Reunion
✅ Episode 251 | Matt Taylor on Brand Building, Storytelling, and Creative Expression
✅ The Psychology of Being a Super Communicator | Charles Duhigg
✅ Gabriela Gajanová - Growing together & following your heart to new records
📋 Manifest updated (17 episodes tracked)
Showing off my favorite podcasts. Sneaky, right?
The whole thing is about 300 lines of Python. The only dependencies are requests, python-dotenv, and podcast-dl. It runs in a venv, reads credentials from .env, and does exactly one thing each time you plug in the watch.
The only thing I haven't figured out is how to make sure that the headphones are charged before I leave for my run. I’m afraid there is no automatic solution to that problem.
If you set this up and hit snags, let me know by creating an issue on codeberg.org
Discussion in the ATmosphere