add excellent wallpaper handler
This commit is contained in:
+637
@@ -0,0 +1,637 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import random as _random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
WALLPAPERS_DIR = str(
|
||||
pathlib.Path(
|
||||
os.environ.get("WALLPAPERS_DIR")
|
||||
or pathlib.Path.home() / "Pictures" / "Wallpapers"
|
||||
).expanduser()
|
||||
)
|
||||
SYMLINK_PATH = str(pathlib.Path("~/Pictures/wallpaper.png").expanduser())
|
||||
|
||||
# old_print = print
|
||||
|
||||
|
||||
# def new_print(*args, **kwargs):
|
||||
# old_print(*args, **kwargs)
|
||||
# old_print("\r", end="")
|
||||
|
||||
|
||||
# print = new_print
|
||||
|
||||
|
||||
def check_setup() -> None:
|
||||
# Check required programs are available
|
||||
for prog in ["magick", "curl"]:
|
||||
try:
|
||||
subprocess.run([prog, "--version"], capture_output=True)
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: Required program '{prog}' not found in PATH.")
|
||||
exit(1)
|
||||
|
||||
for prog in ["zenity", "yad"]:
|
||||
try:
|
||||
subprocess.run([prog, "--version"], capture_output=True)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
else:
|
||||
print("Warning: No GUI file chooser (zenity or yad) found in PATH.")
|
||||
exit(1)
|
||||
|
||||
for prog in ["swww", "hyprpanel", "wal"]:
|
||||
try:
|
||||
subprocess.run([prog, "--version"], capture_output=True)
|
||||
except FileNotFoundError:
|
||||
print(f"Note: Optional program '{prog}' not found in PATH.")
|
||||
|
||||
wallpaper_dir = pathlib.Path(WALLPAPERS_DIR)
|
||||
if not wallpaper_dir.is_dir():
|
||||
print(f"Creating wallpapers directory: {WALLPAPERS_DIR}")
|
||||
wallpaper_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return
|
||||
|
||||
|
||||
def get_path(file: bool = False, multiple: bool = False) -> list[str]:
|
||||
# Try common Linux GUI choosers (zenity, yad).
|
||||
# Returns a list of selected paths (absolute).
|
||||
|
||||
def _normalize_output(raw: str) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
# zenity/yad often use a separator, kdialog/newlines, some use '|' or NUL
|
||||
for sep in ("\x00", "::", "|"):
|
||||
raw = raw.replace(sep, "\n")
|
||||
parts = [p.strip() for p in raw.splitlines() if p.strip()]
|
||||
return [os.path.abspath(os.path.expanduser(p)) for p in parts]
|
||||
|
||||
chooser_commands = []
|
||||
|
||||
if file:
|
||||
if multiple:
|
||||
chooser_commands = [
|
||||
["zenity", "--file-selection", "--multiple", "--separator=::"],
|
||||
["yad", "--file", "--multiple", "--separator=::"],
|
||||
]
|
||||
else:
|
||||
chooser_commands = [
|
||||
["zenity", "--file-selection"],
|
||||
["yad", "--file"],
|
||||
]
|
||||
else:
|
||||
if multiple:
|
||||
chooser_commands = [
|
||||
[
|
||||
"zenity",
|
||||
"--file-selection",
|
||||
"--directory",
|
||||
"--multiple",
|
||||
"--separator=::",
|
||||
],
|
||||
["yad", "--file", "--directory", "--multiple", "--separator=::"],
|
||||
]
|
||||
else:
|
||||
chooser_commands = [
|
||||
["zenity", "--file-selection", "--directory"],
|
||||
["yad", "--file", "--directory"],
|
||||
]
|
||||
|
||||
for cmd in chooser_commands:
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
out = p.stdout.strip()
|
||||
if p.returncode == 0 and out:
|
||||
return _normalize_output(out) if multiple else _normalize_output(out)[:1]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def add_wallpaper(path: list[str], remove_original: bool = False) -> None:
|
||||
for pic in path:
|
||||
pic_path = pathlib.Path(pic)
|
||||
print(f"Adding wallpaper: {pic}")
|
||||
filename = f"wp{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}_{pic_path.name[:10]}.png"
|
||||
# ensure target directory exists
|
||||
target_dir = pathlib.Path(WALLPAPERS_DIR)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest = target_dir / filename
|
||||
|
||||
try:
|
||||
# convert/source -> PNG using ImageMagick
|
||||
# TODO format to res?
|
||||
subprocess.run(["magick", str(pic_path), str(dest)], check=True)
|
||||
print(f"Saved converted wallpaper to: {dest}")
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"ImageMagick 'magick' not found. Please install ImageMagick or ensure it's in PATH."
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"ImageMagick conversion failed: {e}")
|
||||
|
||||
if remove_original:
|
||||
print(f"Removing original file: {pic}")
|
||||
try:
|
||||
pic_path.unlink()
|
||||
except Exception as e:
|
||||
print(f"Failed to remove original file: {e}")
|
||||
|
||||
return
|
||||
|
||||
|
||||
def fetch_wallpaper(urls_or_ids: list[str] | None = None) -> None:
|
||||
|
||||
TEMP_DIR = "/tmp/wallhaven_downloads"
|
||||
TARGET_RESOLUTION = os.environ.get("WALLPAPER_TARGET_RESOLUTION", "2560x1600")
|
||||
|
||||
pathlib.Path(TEMP_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_api_key() -> str:
|
||||
key = os.environ.get("WALLHAVEN_API_KEY") or os.environ.get("WALLPAPER_API_KEY")
|
||||
if key:
|
||||
return key.strip()
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["pass", "api/wallhaven"], capture_output=True, text=True
|
||||
)
|
||||
if p.returncode == 0:
|
||||
return p.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
api_key = _get_api_key()
|
||||
|
||||
items = urls_or_ids or []
|
||||
if not items:
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["wl-paste", "--no-newline"], capture_output=True, text=True
|
||||
)
|
||||
clip = p.stdout.strip() if p.returncode == 0 else ""
|
||||
except FileNotFoundError:
|
||||
clip = ""
|
||||
if not clip:
|
||||
print("No URL/id in clipboard")
|
||||
return
|
||||
items = [clip]
|
||||
|
||||
for item in items:
|
||||
# Extract ID (last path segment, strip trailing slash and query)
|
||||
parsed = urllib.parse.urlparse(item)
|
||||
path = parsed.path or item
|
||||
wallpaper_id = re.sub(r".*/", "", path)
|
||||
wallpaper_id = re.split(r"[/?]", wallpaper_id)[0].strip()
|
||||
|
||||
if not wallpaper_id:
|
||||
print(f"Could not extract id from URL: {item}")
|
||||
continue
|
||||
|
||||
if not re.match(r"^[A-Za-z0-9]{4,8}$", wallpaper_id):
|
||||
print(f"Invalid wallhaven ID format: {wallpaper_id}")
|
||||
continue
|
||||
|
||||
api_url = f"https://wallhaven.cc/api/v1/w/{wallpaper_id}"
|
||||
if api_key:
|
||||
api_url += f"?apikey={api_key}"
|
||||
|
||||
print(f"Fetching wallpaper info for ID: {wallpaper_id}")
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["curl", "-s", "-f", api_url], capture_output=True, text=True
|
||||
)
|
||||
if p.returncode != 0:
|
||||
print(
|
||||
f"Failed to fetch data from wallhaven API (curl exit: {p.returncode})"
|
||||
)
|
||||
continue
|
||||
response = p.stdout
|
||||
except FileNotFoundError:
|
||||
print("curl not found; cannot fetch wallpaper metadata")
|
||||
return
|
||||
|
||||
try:
|
||||
resp_json = json.loads(response)
|
||||
except json.JSONDecodeError:
|
||||
print("Failed to parse API response as JSON")
|
||||
continue
|
||||
|
||||
if resp_json.get("error"):
|
||||
print(f"API Error: {resp_json.get('error')}")
|
||||
continue
|
||||
|
||||
data = resp_json.get("data")
|
||||
if not data:
|
||||
print("No wallpaper data found in API response")
|
||||
continue
|
||||
|
||||
image_url = data.get("path")
|
||||
resolution = data.get("resolution")
|
||||
file_type = data.get("file_type")
|
||||
file_size = data.get("file_size")
|
||||
purity = data.get("purity")
|
||||
|
||||
if not image_url:
|
||||
print("Could not extract image URL from API response")
|
||||
continue
|
||||
|
||||
print("Wallpaper details:")
|
||||
print(f" Resolution: {resolution}")
|
||||
print(f" File type: {file_type}")
|
||||
print(f" File size: {((file_size or 0) // 1024)} KB")
|
||||
print(f" Purity: {purity}")
|
||||
print(f" URL: {image_url}")
|
||||
|
||||
if resolution and resolution != TARGET_RESOLUTION:
|
||||
print(
|
||||
f"Warning: Wallpaper resolution is {resolution} (target: {TARGET_RESOLUTION})"
|
||||
)
|
||||
|
||||
filename = f"{wallpaper_id}"
|
||||
full_path = pathlib.Path(TEMP_DIR) / filename
|
||||
|
||||
try:
|
||||
with (
|
||||
urllib.request.urlopen(image_url) as r,
|
||||
open(full_path, "wb") as out_f,
|
||||
):
|
||||
shutil.copyfileobj(r, out_f)
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to download the wallpaper from: {image_url} ({e})")
|
||||
continue
|
||||
|
||||
if not full_path.is_file():
|
||||
print("Error: Downloaded file is missing")
|
||||
continue
|
||||
|
||||
try:
|
||||
size = full_path.stat().st_size
|
||||
except OSError as e:
|
||||
print(f"Error: Could not stat downloaded file: {e}")
|
||||
continue
|
||||
|
||||
if size == 0:
|
||||
print("Error: Downloaded file is empty")
|
||||
continue
|
||||
|
||||
print(f"Download successful: {full_path} ({size // 1024} KB)")
|
||||
|
||||
add_wallpaper([str(full_path)], remove_original=True)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["zenity", "--notification", "--text", "Wallpaper downloaded!"]
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
|
||||
def next_wallpaper(
|
||||
path: str | None = None, random: bool = False, interactive: bool = False
|
||||
) -> None:
|
||||
|
||||
symlink = pathlib.Path(SYMLINK_PATH)
|
||||
wallpapers_dir = pathlib.Path(WALLPAPERS_DIR) # use module-level constant
|
||||
|
||||
def _atomic_symlink(target: str, link_path: pathlib.Path) -> None:
|
||||
tmp_name = link_path.parent / f".{link_path.name}.tmp.{uuid.uuid4().hex}"
|
||||
try:
|
||||
os.symlink(target, tmp_name)
|
||||
os.replace(tmp_name, link_path)
|
||||
finally:
|
||||
try:
|
||||
if tmp_name.exists() or tmp_name.is_symlink():
|
||||
tmp_name.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_wallpaper(target: str) -> None:
|
||||
print(f"Setting wallpaper to: {target}")
|
||||
_atomic_symlink(target, symlink)
|
||||
|
||||
# trigger external tools; ignore missing commands
|
||||
try:
|
||||
subprocess.run(
|
||||
["swww", "img", target, "--transition-type", "fade"], check=False
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
subprocess.run(["hyprpanel", "-q"], check=False)
|
||||
# restart in background
|
||||
subprocess.Popen(
|
||||
["hyprpanel"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
subprocess.run(["wal", "-c"], check=False)
|
||||
subprocess.run(["wal", "-n", "-i", target], check=False)
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# If interactive requested, ask user for a file
|
||||
if not path and interactive:
|
||||
selected = get_path(file=True, multiple=False)
|
||||
if not selected:
|
||||
print("No file selected interactively.")
|
||||
return
|
||||
path = selected[0]
|
||||
|
||||
# If a file path was provided (force), validate and apply immediately
|
||||
if path:
|
||||
p = pathlib.Path(path).expanduser()
|
||||
if not p.is_file():
|
||||
print(f"Specified wallpaper not found: {p}")
|
||||
return
|
||||
|
||||
target = str(p.resolve())
|
||||
_apply_wallpaper(target)
|
||||
return
|
||||
|
||||
# No explicit path: choose from wallpapers dir
|
||||
if not wallpapers_dir.is_dir():
|
||||
print(f"Wallpapers directory not found: {wallpapers_dir}")
|
||||
return
|
||||
|
||||
# Resolve current wallpaper target (if any)
|
||||
try:
|
||||
current = symlink.resolve(strict=False)
|
||||
current_str = str(current) if current.exists() else ""
|
||||
except Exception:
|
||||
current_str = ""
|
||||
|
||||
def _collect_png_files(wallpapers_dir: pathlib.Path, files: list[pathlib.Path]):
|
||||
# If directory contains a ".off" file → skip entire directory
|
||||
# for item in wallpapers_dir.iterdir():
|
||||
# print(f"Checking item: {item}")
|
||||
if any(item.name == ".off" for item in wallpapers_dir.iterdir()):
|
||||
print(f"Skipping folder (contains .off): {wallpapers_dir}")
|
||||
return
|
||||
|
||||
for item in wallpapers_dir.iterdir():
|
||||
if item.is_file():
|
||||
if item.suffix.lower() == ".png":
|
||||
# print(f"Found wallpaper file: {item}")
|
||||
files.append(item)
|
||||
|
||||
elif item.is_dir():
|
||||
_collect_png_files(item, files)
|
||||
|
||||
files = []
|
||||
_collect_png_files(wallpapers_dir, files)
|
||||
files.sort(key=lambda p: p.name.lower())
|
||||
files = [str(f) for f in files]
|
||||
|
||||
if not files:
|
||||
print(f"No png wallpapers found in {wallpapers_dir}")
|
||||
return
|
||||
|
||||
def choose_random_excluding_current() -> str:
|
||||
candidates = [
|
||||
f for f in files if os.path.realpath(f) != os.path.realpath(current_str)
|
||||
]
|
||||
if not candidates:
|
||||
print("No other wallpaper to choose (only current exists).")
|
||||
raise SystemExit(1)
|
||||
return _random.choice(candidates)
|
||||
|
||||
def choose_next_in_list() -> str:
|
||||
n = len(files)
|
||||
if n == 1:
|
||||
if os.path.realpath(files[0]) == os.path.realpath(current_str):
|
||||
print("No other wallpaper to choose (only current exists).")
|
||||
raise SystemExit(1)
|
||||
return files[0]
|
||||
# find current index
|
||||
idx = -1
|
||||
for i, f in enumerate(files):
|
||||
try:
|
||||
if os.path.realpath(f) == os.path.realpath(current_str):
|
||||
idx = i
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if idx == -1:
|
||||
return files[0]
|
||||
next_idx = (idx + 1) % n
|
||||
return files[next_idx]
|
||||
|
||||
try:
|
||||
new = choose_random_excluding_current() if random else choose_next_in_list()
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
new = str(pathlib.Path(new).resolve())
|
||||
print(f"Chosen wallpaper: {new}")
|
||||
|
||||
_apply_wallpaper(new)
|
||||
|
||||
return
|
||||
|
||||
|
||||
def switch_folders(folders: list[str], on: bool = False, off: bool = False) -> None:
|
||||
if on and off:
|
||||
print("Cannot specify both --on and --off.")
|
||||
return
|
||||
|
||||
for folder_name in folders:
|
||||
folder_path = pathlib.Path(WALLPAPERS_DIR) / folder_name
|
||||
if not folder_path.is_dir():
|
||||
print(f"Folder not found: {folder_path}")
|
||||
continue
|
||||
|
||||
off_file = folder_path / ".off"
|
||||
if on:
|
||||
if off_file.is_file():
|
||||
off_file.unlink()
|
||||
print(f"Turned ON folder: {folder_name}")
|
||||
else:
|
||||
print(f"Folder already ON: {folder_name}")
|
||||
elif off:
|
||||
if not off_file.is_file():
|
||||
off_file.touch()
|
||||
print(f"Turned OFF folder: {folder_name}")
|
||||
else:
|
||||
print(f"Folder already OFF: {folder_name}")
|
||||
else:
|
||||
# Toggle state
|
||||
if off_file.is_file():
|
||||
off_file.unlink()
|
||||
print(f"Turned ON folder: {folder_name}")
|
||||
else:
|
||||
off_file.touch()
|
||||
print(f"Turned OFF folder: {folder_name}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="Wallpaper Setter",
|
||||
description="This script do basic actions with wallpaper for Hyprland",
|
||||
epilog="Designed by Kr0sH_512",
|
||||
)
|
||||
|
||||
coparser = parser.add_subparsers(
|
||||
required=True, dest="command", title="commands", metavar="COMMAND"
|
||||
)
|
||||
# --- Add pic to wallpaper folder or run interactive mode ---
|
||||
add_coparser = coparser.add_parser(
|
||||
"add", aliases=["a", "move", "m"], help="Add wallpaper"
|
||||
)
|
||||
add_coparser.add_argument(
|
||||
"-r",
|
||||
"--remove",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Remove original file(s) after adding",
|
||||
)
|
||||
add_coparser.add_argument(
|
||||
"-f",
|
||||
"--file",
|
||||
type=str,
|
||||
help="Path to wallpaper to add (interactive if not specified)",
|
||||
required=False,
|
||||
nargs="*",
|
||||
)
|
||||
# --- Download wallpaper from Wallhaven by URL or ID ---
|
||||
down_coparser = coparser.add_parser(
|
||||
"download", aliases=["d", "fetch", "f"], help="Download wallpaper"
|
||||
)
|
||||
down_coparser.add_argument(
|
||||
"-u",
|
||||
"--urls_or_ids",
|
||||
type=str,
|
||||
help="Wallhaven wallpaper URL(s) or ID(s) to download (clipboard if not specified)",
|
||||
required=False,
|
||||
nargs="*",
|
||||
)
|
||||
# --- Select wallpaper to set (interactive, by file or by order) ---
|
||||
next_coparser = coparser.add_parser("next", aliases=["n"], help="Next wallpaper")
|
||||
next_coparser.add_argument(
|
||||
"-r",
|
||||
"--random",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Set a random wallpaper (if no file is specified)",
|
||||
)
|
||||
# TODO: if file is path to directory, pick from there
|
||||
next_coparser.add_argument(
|
||||
"-f",
|
||||
"--file",
|
||||
type=str,
|
||||
help="Set wallpaper by file path",
|
||||
required=False,
|
||||
)
|
||||
next_coparser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Set wallpaper interactively",
|
||||
)
|
||||
# --- List wallpapers in directory (and info) ---
|
||||
list_coparser = coparser.add_parser(
|
||||
"list", aliases=["l"], help="List and count wallpapers"
|
||||
)
|
||||
list_coparser.add_argument(
|
||||
"-f",
|
||||
"--file",
|
||||
type=str,
|
||||
help="Path to list wallpapers from (defaults to wallpapers directory)",
|
||||
required=False,
|
||||
)
|
||||
list_coparser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show detailed information about wallpapers",
|
||||
)
|
||||
# --- Switch on/off wallpaper folders ---
|
||||
switch_coparser = coparser.add_parser(
|
||||
"switch", aliases=["sw"], help="Turn on/off folders in $WALLPAPERS_DIR"
|
||||
)
|
||||
switch_coparser.add_argument(
|
||||
"-0",
|
||||
"--off",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Turn off specified folder(s) (switch state if not specified)",
|
||||
)
|
||||
switch_coparser.add_argument(
|
||||
"-1",
|
||||
"--on",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Turn on specified folder(s) (switch state if not specified)",
|
||||
)
|
||||
switch_coparser.add_argument(
|
||||
"folders",
|
||||
help="Folder(s) name to switch (interactive if not specified)",
|
||||
nargs="+",
|
||||
)
|
||||
# --- Get info about current wallpaper and folders ---
|
||||
info_coparser = coparser.add_parser(
|
||||
"info", aliases=["i"], help="Get info about current wallpaper and folders"
|
||||
)
|
||||
info_coparser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show detailed information",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
# print(args)
|
||||
|
||||
check_setup()
|
||||
# return
|
||||
|
||||
match args.command:
|
||||
case "add" | "a" | "move" | "m":
|
||||
path = args.file if args.file else get_path(file=True, multiple=True)
|
||||
add_wallpaper(path=path, remove_original=args.remove)
|
||||
case "download" | "d" | "fetch" | "f":
|
||||
fetch_wallpaper(urls_or_ids=args.urls_or_ids)
|
||||
case "next" | "n":
|
||||
next_wallpaper(
|
||||
path=args.file, random=args.random, interactive=args.interactive
|
||||
)
|
||||
case "list" | "l":
|
||||
pass
|
||||
case "switch" | "sw":
|
||||
switch_folders(args.folders, on=args.on, off=args.off)
|
||||
case "info" | "i":
|
||||
pass
|
||||
|
||||
# print(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user