667 lines
21 KiB
Python
Executable File
667 lines
21 KiB
Python
Executable File
#!/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("~/Pictures/Wallpapers")
|
|
).expanduser()
|
|
)
|
|
SYMLINK_PATH = str(pathlib.Path("~/Pictures/wallpaper.png").expanduser())
|
|
|
|
|
|
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 list_wallpapers(path: str | None = None, verbose: bool = False) -> None:
|
|
# WIP
|
|
return
|
|
wallpaper_path = pathlib.Path(WALLPAPERS_DIR)
|
|
if path:
|
|
wallpaper_path = wallpaper_path / path
|
|
|
|
if not wallpaper_path.is_dir():
|
|
print(f"Folder not found: {wallpaper_path}")
|
|
return
|
|
|
|
"""
|
|
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)
|
|
"""
|
|
|
|
def _walk_in_dir(
|
|
wallpapers_dir: pathlib.Path, dirs: list[pathlib.Path], deepth: int = 0
|
|
):
|
|
for item in wallpapers_dir.iterdir():
|
|
if item.is_dir():
|
|
print(
|
|
f"{item.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":
|
|
list_wallpapers(path=args.file, verbose=args.verbose)
|
|
case "switch" | "sw":
|
|
switch_folders(args.folders, on=args.on, off=args.off)
|
|
case "info" | "i":
|
|
pass
|
|
|
|
# print(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|