Initial commit

This commit is contained in:
Michael Swanson 2023-10-15 16:42:13 -07:00
commit 1ad97ea586
3 changed files with 396 additions and 0 deletions

162
.gitignore vendored Normal file
View file

@ -0,0 +1,162 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
**/.DS_Store

37
README.md Normal file
View file

@ -0,0 +1,37 @@
# WallGet Live Wallpaper Download Script for macOS
By [Mike Swanson](http://blog.mikeswanson.com/)
I love the live wallpaper videos in macOS Sonoma, but I don't like that I have to download each video individually. So, until Apple adds a "download all" button to their Wallpaper settings, you can use this script.
The script allows you to download just one wallpaper category (e.g. "Earth") at a time, or you can choose to download all categories at once (which currently results in 134 video files and ~65 GB of data). The downloaded files are placed where macOS expects them.
After downloads complete, the script can optionally kill the **idleassetsd** process (or you can just restart your Mac). Either of these operations causes **idleassetsd** to update the now-downloaded status of each file.
## Requirements
This script only makes sense on macOS Sonoma (and presumably future OS releases), and it requires admin permission to write to the correct wallpaper folder.
## Setup
If you just want to run the script, use the **Download raw file** button to save [wallget.py](https://github.com/mikeswanson/wallget/blob/main/wallget.py) to a folder.
Or, if you're a developer:
git clone https://github.com/mikeswanson/wallget.git
## Usage
From **Terminal**, change to the folder that contains **wallget.py**, and execute the script with admin permission:
sudo python3 wallget.py
If you're a non-programmer, you may see a pop-up window asking you to install the command-line developer tools. These are necessary to run the script, so select **Install** and wait for the installation to finish before trying the above command a second time.
After entering your admin password, you should be presented with a numbered list of live wallpaper categories, including a final "All" category. To see the videos in each category, you can preview them in the Settings app under Wallpaper (or right-click the desktop and choose **Change Wallpaper...**). Select a category to continue.
The script determines the required storage space for the selected files, reports the total, and prompts to continue. Note that the script only downloads files that don't exist or have mismatched file sizes (possibly because a prior download failed part-way through). Confirm the download to continue.
When all downloads are complete, you are prompted to optionally kill the **idleassetsd** process so that each wallpaper's download status is correctly reflected in the Settings app. If you choose not to do this, you can simply restart your Mac.
I hope that this is useful!

197
wallget.py Normal file
View file

@ -0,0 +1,197 @@
import http.client
import json
import os
import plistlib
import shutil
import ssl
import time
import urllib.parse
from multiprocessing.pool import ThreadPool
from typing import Tuple
IDLEASSETSD_PATH = "/Library/Application Support/com.apple.idleassetsd"
STRINGS_PATH = f"{IDLEASSETSD_PATH}/Customer/TVIdleScreenStrings.bundle/en.lproj/Localizable.nocache.strings"
ENTRIES_PATH = f"{IDLEASSETSD_PATH}/Customer/entries.json"
VIDEO_PATH = f"{IDLEASSETSD_PATH}/Customer/4KSDR240FPS"
def main():
# Check if running as admin
if os.geteuid() != 0:
print(f'Please run as admin: sudo python3 "{__file__}"')
exit()
print("WallGet Live Wallpaper Download Script")
print("--------------------------------------\n")
# Validate environment
if not os.path.isdir(IDLEASSETSD_PATH):
print("Unable to find idleassetsd path.")
exit()
if not os.path.isfile(STRINGS_PATH):
print("Unable to find localizable strings file.")
exit()
if not os.path.isfile(ENTRIES_PATH):
print("Unable to find entries.json file.")
exit()
if not os.path.isdir(VIDEO_PATH):
print("Unable to find video path.")
exit()
# Read localizable strings
with open(STRINGS_PATH, "rb") as fp:
strings = plistlib.load(fp)
# Read asset entries
asset_entries = json.load(open(ENTRIES_PATH))
# Show categories
item = 0
categories = asset_entries.get("categories", [])
for category in categories:
name = strings.get(category.get("localizedNameKey", ""), "")
item += 1
print(f"{item}. {name}")
print(f"{item + 1}. All")
# Select category
category_index = as_int(input("\nCategory number to download? "))
if category_index < 1 or category_index > item + 1:
print("\nNo category selected.")
exit()
category_id = (
categories[int(category_index) - 1]["id"] if category_index <= item else None
)
# Determine downloads
print("\nDetermining download size...", end="")
downloads = []
bytes_required = 0
for asset in asset_entries.get("assets", []):
if category_id and category_id not in asset.get("categories", []):
continue
print(".", end="", flush=True)
label = strings.get(asset.get("localizedNameKey", ""), "")
id = asset.get("id", "")
# NOTE: May need to update this key logic if other formats are added
url = asset.get("url-4K-SDR-240FPS", "")
# Valid asset?
if not label or not id or not url:
continue
content_length = get_content_length(url)
path = urllib.parse.urlparse(url).path
ext = os.path.splitext(path)[1]
file_path = f"{VIDEO_PATH}/{id}{ext}"
# Download if file doesn't exist or is the wrong size
if (
not os.path.isfile(file_path)
or os.path.getsize(file_path) != content_length
):
downloads.append((label, url, file_path))
bytes_required += content_length
print("done.\n")
# Anything to download?
if not downloads:
print("Nothing to download.")
exit()
# Disk space check
free_space = shutil.disk_usage("/").free
print(f"Available space: {format_bytes(free_space)}")
print(f"Files to download ({len(downloads)}): {format_bytes(bytes_required)}")
if bytes_required > free_space:
print("Not enough disk space to download all files.")
exit()
proceed = input("Download files? (y/n) ").strip().lower()
if proceed != "y":
exit()
start_time = time.time()
print("\nDownloading...")
results = ThreadPool().imap_unordered(download_file, downloads)
for result in results:
print(f" Downloaded '{result}'")
print(f"\nDownloaded {len(downloads)} files in {time.time() - start_time:.1f}s.")
# Optionally kill idleassetsd to update wallpaper status
should_kill = (
input("\nKill idleassetsd to update download status in Settings? (y/n) ")
.strip()
.lower()
)
if should_kill == "y":
os.system("killall idleassetsd")
print("Killed idleassetsd.")
print("\nDone.")
def as_int(s: str) -> int:
try:
return int(s)
except ValueError:
return -1
def format_bytes(bytes: int) -> str:
units = (
(1 << 50, "PB"),
(1 << 40, "TB"),
(1 << 30, "GB"),
(1 << 20, "MB"),
(1 << 10, "KB"),
(1, "bytes"),
)
if bytes == 1:
return "1 byte"
for factor, suffix in units:
if bytes >= factor:
break
return f"{bytes / factor:.2f} {suffix}"
def connect(parsed_url: urllib.parse.ParseResult) -> http.client.HTTPConnection:
context = ssl._create_unverified_context()
conn = (
http.client.HTTPSConnection(parsed_url.netloc, context=context)
if parsed_url.scheme == "https"
else http.client.HTTPConnection(parsed_url.netloc)
)
return conn
def get_content_length(url: str) -> int:
parsed_url = urllib.parse.urlparse(url)
conn = connect(parsed_url)
conn.request("HEAD", parsed_url.path)
r = conn.getresponse()
content_length = int(r.getheader("Content-Length", -1))
conn.close()
return content_length
def download_file(download: Tuple[str, str, str]) -> str:
label, url, file_path = download
parsed_url = urllib.parse.urlparse(url)
conn = connect(parsed_url)
conn.request("GET", parsed_url.path)
r = conn.getresponse()
if r.status == 200:
with open(file_path, "wb") as f:
shutil.copyfileobj(r, f)
conn.close()
return label
if __name__ == "__main__":
main()