Skip to content

Instantly share code, notes, and snippets.

@CzechJiri
Last active July 20, 2026 13:56
Show Gist options
  • Select an option

  • Save CzechJiri/cf2b5973d7e7f5d0bd7bb755a847f003 to your computer and use it in GitHub Desktop.

Select an option

Save CzechJiri/cf2b5973d7e7f5d0bd7bb755a847f003 to your computer and use it in GitHub Desktop.
I love listening to podcasts while swimming with my Shokz OpenSwim Pro, but it has no Bluetooth/streaming — I have to load MP3s onto it directly. Apple Podcasts already downloads episodes locally for offline playback, but buries them in an obscure app cache folder with no filenames to speak of. This script digs those MP3s out of Apple Podcasts' …
#!/usr/bin/env python3
"""
Copies MP3 files created/modified in the last 2 hours from the Apple
Podcasts local cache into ~/Downloads/Podcasts, renamed to
"Author - Podcast Name.mp3" using each file's ID3 tags.
Any existing .mp3 files in the destination folder are deleted first.
"""
import argparse
import json
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
DEFAULT_DEST_DIR = Path.home() / "Downloads/Podcasts"
DEFAULT_MAX_AGE_MINUTES = 120
MAX_NAME_LEN = 80
def find_podcasts_cache_dir() -> Path:
"""Locate the Apple Podcasts app's group container, whose folder name
is a machine-specific team-ID prefix (e.g. 243LU875E5.groups.com.apple.podcasts)
that isn't safe to hardcode - discover it instead of assuming it."""
group_containers = Path.home() / "Library/Group Containers"
matches = sorted(group_containers.glob("*.groups.com.apple.podcasts"))
if not matches:
raise FileNotFoundError(
f"Could not find an Apple Podcasts group container under:\n {group_containers}"
)
return matches[0] / "Library/Cache"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--src", type=Path, default=None,
help="Source directory to scan for mp3s "
"(default: auto-detected Apple Podcasts cache folder)")
parser.add_argument("--dest", type=Path, default=DEFAULT_DEST_DIR,
help=f"Destination directory (default: {DEFAULT_DEST_DIR})")
parser.add_argument("--max-age-minutes", type=float, default=DEFAULT_MAX_AGE_MINUTES,
help=f"Only copy files modified within this many minutes (default: {DEFAULT_MAX_AGE_MINUTES})")
return parser.parse_args()
def clean(value: str, fallback: str) -> str:
if not value:
return fallback
# Some publishers stuff a legal/AI-training notice after the real
# name, separated by a newline or a "©" - keep only what's before it.
first = re.split(r"[\n\r©]", value)[0].strip().rstrip(" ,.")
if len(first) > MAX_NAME_LEN:
first = first[:MAX_NAME_LEN].rstrip()
return first or fallback
def read_tags(path: Path) -> dict:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries",
"format_tags=album,album_artist,artist", "-of", "json", str(path)],
capture_output=True, text=True, check=True,
)
return json.loads(result.stdout).get("format", {}).get("tags", {})
def dest_name_for(path: Path) -> str:
tags = read_tags(path)
author = clean(tags.get("album_artist") or tags.get("artist"), "Unknown Author")
podcast = clean(tags.get("album"), "Unknown Podcast")
name = f"{author} - {podcast}"
name = re.sub(r'[/:*?"<>|]', "-", name)
return re.sub(r"\s+", " ", name).strip()
def unique_path(directory: Path, name: str, suffix: str) -> Path:
candidate = directory / f"{name}{suffix}"
counter = 2
while candidate.exists():
candidate = directory / f"{name} ({counter}){suffix}"
counter += 1
return candidate
def main() -> int:
args = parse_args()
dest_dir: Path = args.dest
if args.src is not None:
src_dir = args.src
else:
try:
src_dir = find_podcasts_cache_dir()
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if not src_dir.is_dir():
print(f"Error: source folder not found at:\n {src_dir}", file=sys.stderr)
return 1
if shutil.which("ffprobe") is None:
print("Error: ffprobe is required to read podcast metadata but was not found.\n"
"Install it with: brew install ffmpeg", file=sys.stderr)
return 1
dest_dir.mkdir(parents=True, exist_ok=True)
for existing in dest_dir.glob("*.mp3"):
existing.unlink()
cutoff = time.time() - args.max_age_minutes * 60
copied = 0
for src_file in src_dir.glob("*.mp3"):
if src_file.stat().st_mtime < cutoff:
continue
name = dest_name_for(src_file)
dest_path = unique_path(dest_dir, name, ".mp3")
shutil.copy2(src_file, dest_path)
copied += 1
print(f"Copied {copied} mp3 file(s) to {dest_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment