A Python Shortcut for Downloading macOS Sonoma Aerial Screensavers

Published:

Starting with macOS Sonoma, Apple added a new set of screensavers for the Mac.

macOS Sonoma aerial screensavers

These are 4K aerial videos, and they can also be used as wallpapers. When you switch back from the screensaver to the desktop, macOS plays a smooth animated transition, which looks great. There are also quite a few options to choose from.

Because the files are 4K, they take up a lot of space, so Apple does not preinstall all of them on the system. Aside from a few defaults, the rest have to be downloaded manually from Settings, and that download is often painfully slow — sometimes only a few KB per second.

Slow screensaver downloads in Settings

After checking where macOS stores them, I found that the screensaver files live under /Library/Application\ Support/com.apple.idleassetsd/Customer, while the metadata is kept in entries.json in the same directory.

Once that was clear, the rest was straightforward: I wrote a Python script that can batch-download all of these screensavers directly.

Script

  1import json
  2import asyncio
  3import sys
  4from argparse import ArgumentParser
  5from pathlib import Path
  6import plistlib
  7
  8import httpx
  9from tqdm import tqdm
 10
 11BASE_PATH = Path("/Library/Application Support/com.apple.idleassetsd/Customer")
 12DEST_PATH = BASE_PATH / "4KSDR240FPS"
 13ENTRIES_FILE = BASE_PATH / "entries.json"
 14LOCALIZABLE_FILE = BASE_PATH / "TVIdleScreenStrings.bundle/zh_CN.lproj/Localizable.nocache.strings"
 15
 16def load_localizable_strings() -> dict:
 17    strings = {}
 18    if LOCALIZABLE_FILE.exists():
 19        with LOCALIZABLE_FILE.open("rb") as f:
 20            plist_data = plistlib.load(f)
 21            strings.update(plist_data)
 22    return strings
 23
 24def get_localized_name(key: str, strings: dict) -> str:
 25    return strings.get(key, key)
 26
 27def download_asset_sync(item: dict, dst: Path):
 28    name = f"{item['categoryName']}: {item['assetName']}"
 29    tqdm.write(f"Downloading: {name}")
 30    try:
 31        with dst.open("wb") as download_file:
 32            with httpx.stream("GET", item["url-4K-SDR-240FPS"], verify=False) as response:
 33                total = int(response.headers.get("Content-Length", 0))
 34                with tqdm(total=total, unit="B", unit_scale=True, unit_divisor=1024, desc=name, position=1, leave=False) as progress:
 35                    num_bytes_downloaded = response.num_bytes_downloaded
 36                    for chunk in response.iter_bytes():
 37                        download_file.write(chunk)
 38                        progress.update(response.num_bytes_downloaded - num_bytes_downloaded)
 39                        num_bytes_downloaded = response.num_bytes_downloaded
 40    except (httpx.RequestError, httpx.HTTPStatusError) as e:
 41        tqdm.write(f"Error downloading {name}: {e}")
 42
 43async def download_asset_async(client, item: dict, dst: Path, position: int):
 44    name = f"{item['categoryName']}: {item['assetName']}"
 45    tqdm.write(f"Downloading: {name}")
 46    try:
 47        async with client.stream("GET", item["url-4K-SDR-240FPS"]) as response:
 48            total = int(response.headers.get("Content-Length", 0))
 49            with tqdm(total=total, unit="B", unit_scale=True, unit_divisor=1024, desc=name, position=position, leave=False) as progress:
 50                with dst.open("wb") as download_file:
 51                    async for chunk in response.aiter_bytes():
 52                        download_file.write(chunk)
 53                        progress.update(len(chunk))
 54    except (httpx.RequestError, httpx.HTTPStatusError) as e:
 55        tqdm.write(f"Error downloading {name}: {e}")
 56
 57async def download_asset_concurrent(items: list, max_concurrent: int = 5):
 58    async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
 59        tasks = []
 60        pending_items = [item for item in items if not (DEST_PATH / f"{item['id']}.mov").exists() and item.get("url-4K-SDR-240FPS")]
 61
 62        # 预留进度条行
 63        for i in range(min(max_concurrent, len(pending_items))):
 64            print(f"\033[K", end="")  # 清除行
 65            print()  # 预留一行
 66        print(f"\033[{min(max_concurrent, len(pending_items))}A", end="", flush=True)  # 移动光标到第一行
 67
 68        for index, item in enumerate(pending_items):
 69            position = (index % max_concurrent) + 1  # 分配行号(1 到 max_concurrent)
 70            tasks.append(download_asset_async(client, item, DEST_PATH / f"{item['id']}.mov", position))
 71            if len(tasks) >= max_concurrent:
 72                await asyncio.gather(*tasks, return_exceptions=True)
 73                tasks = []
 74        if tasks:
 75            await asyncio.gather(*tasks, return_exceptions=True)
 76
 77    # 清除进度条区域
 78    print(f"\033[{min(max_concurrent, len(pending_items))}A", end="", flush=True)
 79    for _ in range(min(max_concurrent, len(pending_items))):
 80        print(f"\033[K", end="")  # 清除行
 81        print()
 82
 83def main():
 84    parser = ArgumentParser(description="Download macOS Aerial screensaver assets")
 85    parser.add_argument("--batch", nargs="?", const=5, type=int, metavar="SIZE", help="Use concurrent downloads, 5 tasks by default")
 86    args = parser.parse_args()
 87
 88    if not ENTRIES_FILE.exists():
 89        print(f"Error: {ENTRIES_FILE} not found")
 90        sys.exit(1)
 91    with ENTRIES_FILE.open() as f:
 92        data = json.load(f)
 93
 94    localizable_strings = load_localizable_strings()
 95
 96    categories = {}
 97    for category in data.get("categories", []):
 98        category_name = get_localized_name(category["localizedNameKey"], localizable_strings)
 99        categories[category["id"]] = category_name
100
101    for asset in data.get("assets", []):
102        category_id = asset.get("categories", [""])[0]
103        asset["categoryName"] = categories.get(category_id, "")
104        asset["assetName"] = get_localized_name(asset["localizedNameKey"], localizable_strings)
105
106    DEST_PATH.mkdir(parents=True, exist_ok=True)
107
108    if args.batch:
109        asyncio.run(download_asset_concurrent(data.get("assets", []), max_concurrent=args.batch))
110    else:
111        for item in tqdm(data.get("assets", []), desc="Processing assets", position=0):
112            dst = DEST_PATH / f"{item['id']}.mov"
113            if not dst.exists() and item.get("url-4K-SDR-240FPS"):
114                download_asset_sync(item, dst)
115
116    print("Done")
117
118if __name__ == "__main__":
119    main()

How to use it

First, make sure the two pip dependencies below are installed:

/usr/bin/pip3 install httpx tqdm

Create a new screensaver.py file, paste the code into it, then run the script in Terminal:

sudo /usr/python3 screensaver.py

Batch downloading on macOS

By default, the script downloads one file at a time. If you want faster batch downloads, you can pass the --batch argument:

sudo /usr/python3 screensaver.py --batch 5

Concurrent download progress

After everything is downloaded, the files add up to 66 GB, so this is definitely not light on disk space.

A small note: after the download finishes, you may need to log out and back in, or restart the Mac, before the screensavers appear correctly in Settings.

If you ever want to remove them, just delete the /Library/Application\ Support/com.apple.idleassetsd/Customer/4KSDR240FPS folder.