Last active
July 20, 2026 13:56
-
-
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' …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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