Léon

醒石

鸵鸟将头埋进土里,以此躲避危机。 我们也需要一个洞穴,暂时藏身,让心灵喘息。 欢迎来到我的洞穴。

使用 Python 腳本批量下載 macOS 航拍螢幕保護程式/壁紙

自從 macOS Sonoma 版本開始,Apple 為 Mac 提供了新的螢幕保護程式。

image

這些螢保都是 4K 航拍視頻,可同時設置為壁紙。設置為壁紙後從螢保切回桌面會有一段動態過渡動畫。效果非常不錯,種類也很多。

由於是 4K 畫質,很佔空間,這些螢保並沒有預載在系統裡。除了預設的幾款螢保,其他都需要在設置中點擊後等待程序自動下載,但是下載速度非常慢,經常只有十幾 kb。

image

查了一下資料,這些螢保都存儲在 /Library/Application\ Support/com.apple.idleassetsd/Customer 目錄下,而元數據則存儲在同目錄下的 entries.json 文件中。

那事情就好辦了,我寫了一個 Python 腳本,可以直接批量將這些螢保下載下來。

腳本代碼#

import json
import asyncio
import sys
from argparse import ArgumentParser
from pathlib import Path
import plistlib

import httpx
from tqdm import tqdm

BASE_PATH = Path("/Library/Application Support/com.apple.idleassetsd/Customer")
DEST_PATH = BASE_PATH / "4KSDR240FPS"
ENTRIES_FILE = BASE_PATH / "entries.json"
LOCALIZABLE_FILE = BASE_PATH / "TVIdleScreenStrings.bundle/zh_CN.lproj/Localizable.nocache.strings"

def load_localizable_strings() -> dict:
    strings = {}
    if LOCALIZABLE_FILE.exists():
        with LOCALIZABLE_FILE.open("rb") as f:
            plist_data = plistlib.load(f)
            strings.update(plist_data)
    return strings

def get_localized_name(key: str, strings: dict) -> str:
    return strings.get(key, key)

def download_asset_sync(item: dict, dst: Path):
    name = f"{item['categoryName']}: {item['assetName']}"
    tqdm.write(f"Downloading: {name}")
    try:
        with dst.open("wb") as download_file:
            with httpx.stream("GET", item["url-4K-SDR-240FPS"], verify=False) as response:
                total = int(response.headers.get("Content-Length", 0))
                with tqdm(total=total, unit="B", unit_scale=True, unit_divisor=1024, desc=name, position=1, leave=False) as progress:
                    num_bytes_downloaded = response.num_bytes_downloaded
                    for chunk in response.iter_bytes():
                        download_file.write(chunk)
                        progress.update(response.num_bytes_downloaded - num_bytes_downloaded)
                        num_bytes_downloaded = response.num_bytes_downloaded
    except (httpx.RequestError, httpx.HTTPStatusError) as e:
        tqdm.write(f"Error downloading {name}: {e}")

async def download_asset_async(client, item: dict, dst: Path, position: int):
    name = f"{item['categoryName']}: {item['assetName']}"
    tqdm.write(f"Downloading: {name}")
    try:
        async with client.stream("GET", item["url-4K-SDR-240FPS"]) as response:
            total = int(response.headers.get("Content-Length", 0))
            with tqdm(total=total, unit="B", unit_scale=True, unit_divisor=1024, desc=name, position=position, leave=False) as progress:
                with dst.open("wb") as download_file:
                    async for chunk in response.aiter_bytes():
                        download_file.write(chunk)
                        progress.update(len(chunk))
    except (httpx.RequestError, httpx.HTTPStatusError) as e:
        tqdm.write(f"Error downloading {name}: {e}")

async def download_asset_concurrent(items: list, max_concurrent: int = 5):
    async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
        tasks = []
        pending_items = [item for item in items if not (DEST_PATH / f"{item['id']}.mov").exists() and item.get("url-4K-SDR-240FPS")]

        # 預留進度條行
        for i in range(min(max_concurrent, len(pending_items))):
            print(f"\033[K", end="")  # 清除行
            print()  # 預留一行
        print(f"\033[{min(max_concurrent, len(pending_items))}A", end="", flush=True)  # 移動光標到第一行

        for index, item in enumerate(pending_items):
            position = (index % max_concurrent) + 1  # 分配行號(1 到 max_concurrent)
            tasks.append(download_asset_async(client, item, DEST_PATH / f"{item['id']}.mov", position))
            if len(tasks) >= max_concurrent:
                await asyncio.gather(*tasks, return_exceptions=True)
                tasks = []
        if tasks:
            await asyncio.gather(*tasks, return_exceptions=True)

    # 清除進度條區域
    print(f"\033[{min(max_concurrent, len(pending_items))}A", end="", flush=True)
    for _ in range(min(max_concurrent, len(pending_items))):
        print(f"\033[K", end="")  # 清除行
        print()

def main():
    parser = ArgumentParser(description="Download macOS Aerial screensaver assets")
    parser.add_argument("--batch", nargs="?", const=5, type=int, metavar="SIZE", help="Use concurrent downloads, 5 tasks by default")
    args = parser.parse_args()

    if not ENTRIES_FILE.exists():
        print(f"Error: {ENTRIES_FILE} not found")
        sys.exit(1)
    with ENTRIES_FILE.open() as f:
        data = json.load(f)

    localizable_strings = load_localizable_strings()

    categories = {}
    for category in data.get("categories", []):
        category_name = get_localized_name(category["localizedNameKey"], localizable_strings)
        categories[category["id"]] = category_name

    for asset in data.get("assets", []):
        category_id = asset.get("categories", [""])[0]
        asset["categoryName"] = categories.get(category_id, "")
        asset["assetName"] = get_localized_name(asset["localizedNameKey"], localizable_strings)

    DEST_PATH.mkdir(parents=True, exist_ok=True)

    if args.batch:
        asyncio.run(download_asset_concurrent(data.get("assets", []), max_concurrent=args.batch))
    else:
        for item in tqdm(data.get("assets", []), desc="Processing assets", position=0):
            dst = DEST_PATH / f"{item['id']}.mov"
            if not dst.exists() and item.get("url-4K-SDR-240FPS"):
                download_asset_sync(item, dst)

    print("Done")

if __name__ == "__main__":
    main()

使用方法#

首先確保你安裝了以下兩個 pip 依賴,在終端執行:

/usr/bin/pip3 install httpx tqdm

新建一個 screensaver.py 文件,將代碼內容拷貝到 screensaver.py 中,並在終端中執行 sudo /usr/python3 screensaver.py

image

默認單線程下載,你也可以使用 batch 參數進行批量下載:

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

image

所有壁紙下載完成後有 66G,還是挺佔空間的。

image

P.S. 下載完成後可能需要重新登錄用戶(或重啟)後才能在設置中正常顯示。

P.P.S. 如果需要刪除螢保,清除 /Library/Application\ Support/com.apple.idleassetsd/Customer/4KSDR240FPS 這個文件夾即可。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。