mirror of
https://github.com/mikeswanson/WallGet.git
synced 2025-04-19 06:48:04 +02:00
Initial commit
This commit is contained in:
commit
1ad97ea586
3 changed files with 396 additions and 0 deletions
162
.gitignore
vendored
Normal file
162
.gitignore
vendored
Normal 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
37
README.md
Normal 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
197
wallget.py
Normal 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()
|
Loading…
Add table
Add a link
Reference in a new issue