#!/usr/bin/env python3 import argparse import json from os import environ import random import shutil import subprocess import sys from pathlib import Path from urllib.parse import quote import urllib.request HOME = Path.home() BASE_DIR = HOME / "Pictures" / "Wallpapers" FAV_DIR = BASE_DIR / "favorites" CACHE_DIR = BASE_DIR / "cache" CURRENT = BASE_DIR / "current" STATE_FILE = HOME / ".cache" / "randrwall_state.json" DEFAULT_TAGS = [ "space", "nebula", "cyberpunk", "minimal", "architecture", "landscape", "fantasy", "digital art", ] ATLEAST = "3840x2160" RATIOS = "16x9,21x9" SORTING = "random" CATEGORIES = "110" PURITY = "100" def load_env_file(path: Path) -> None: if not path.exists(): return for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "=" not in line: continue k, v = line.split("=", 1) environ.setdefault(k.strip(), v.strip()) load_env_file(Path() / ".env") API_KEY = environ.get("WALLHAVEN_API_KEY") def run(cmd: list[str]) -> None: subprocess.run(cmd, check=True) def ensure_dirs() -> None: FAV_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True) STATE_FILE.parent.mkdir(parents=True, exist_ok=True) def read_state() -> dict: if STATE_FILE.exists(): try: return json.loads(STATE_FILE.read_text(encoding="utf-8")) except Exception: return {} return {} def write_state(state: dict) -> None: STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8") def is_image(path: Path) -> bool: return path.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"} def swww_set(path: Path) -> None: run( [ "swww", "img", str(path), ] ) def pick_random_from(dir_path: Path) -> Path | None: if not dir_path.exists(): return None imgs = [p for p in dir_path.iterdir() if p.is_file() and is_image(p)] if not imgs: return None return random.choice(imgs) UA = "randrwall/0.1 (+https://gitea.stereov.io/antistereov/randrwall)" HEADERS = { "User-Agent": UA, "Referer": "https://wallhaven.cc/", "Accept": "*/*", } def http_get_json(url: str, timeout: int = 15) -> dict: req = urllib.request.Request(url, headers=HEADERS) with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8")) def wallhaven_search_random(tags: list[str]) -> str: tag = random.choice(tags) q = quote(tag) params = ( f"q={q}" f"&categories={CATEGORIES}" f"$purity={PURITY}" f"&atleast={ATLEAST}" f"&ratios={quote(RATIOS)}" f"&sorting={SORTING}" "&order=desc" "&page=1" ) if API_KEY: print("Using API key") params += f"&apiKey={API_KEY}" url = f"https://wallhaven.cc/api/v1/search?{params}" data = http_get_json(url) arr = data.get("data") or [] if not arr: raise RuntimeError(f"Wallhaven: no results for tag='{tag}'") item = random.choice(arr) path = item.get("path") if not path: raise RuntimeError("Wallhaven: response does not contain 'path'") return path def prune_cache(max_files: int = 10) -> None: if max_files <= 0: return files = [p for p in CACHE_DIR.iterdir() if p.is_file() and is_image(p)] files.sort(key=lambda p: p.stat().st_mtime, reverse=True) for p in files[max_files:]: try: p.unlink() except Exception: pass def download_to_cache(url: str) -> Path: filename = url.split("/")[-1] out = CACHE_DIR / filename if out.exists() and out.stat().st_size > 0: return out tmp_path = CACHE_DIR / (filename + ".part") req = urllib.request.Request(url, headers=HEADERS, method="GET") try: with urllib.request.urlopen(req, timeout=30) as resp: if resp.status != 200: raise RuntimeError(f"Download failed: HTTP {resp.status}") with open(tmp_path, "wb") as f: shutil.copyfileobj(resp, f) tmp_path.replace(out) prune_cache() return out finally: try: if tmp_path.exists(): tmp_path.unlink() except Exception: pass def copy_to_current(img: Path) -> None: shutil.copy2(img, CURRENT) def cmd_random(args: argparse.Namespace) -> None: ensure_dirs() state = read_state() use_fav = random.random() < args.fav_weight fav_pick = pick_random_from(FAV_DIR) if use_fav else None if fav_pick is not None: swww_set(fav_pick) state["current"] = str(fav_pick) state["source"] = "favorites" write_state(state) print(f"Set (favorites): {fav_pick}") copy_to_current(fav_pick) return url = wallhaven_search_random(args.tags) img = download_to_cache(url) swww_set(img) copy_to_current(img) state["current"] = str(img) state["source"] = "wallhaven" state["wallhaven_url"] = url write_state(state) print(f"Set (download): {img}") def cmd_favorite(args: argparse.Namespace) -> None: ensure_dirs() state = read_state() cur = state.get("current") if not cur: print( "No current wallpaper found in state. Please set one first via 'random'", file=sys.stderr, ) sys.exit(1) src = Path(cur) if not src.exists(): print(f"Current wallpaper does not exist anymore: {src}", file=sys.stderr) sys.exit(1) dest = FAV_DIR / src.name if dest.exists(): print(f"Already in favorites: {dest}") return shutil.copy2(src, dest) print(f"Saved to favorites: {dest}") def reload(args: argparse.Namespace) -> None: ensure_dirs() state = read_state() cur = state.get("current") if not cur: print( "No current wallpaper found in state. Please set one first via 'random'", file=sys.stderr, ) sys.exit(1) src = Path(cur) if not src.exists(): print(f"Current wallpaper does not exist anymore: {src}", file=sys.stderr) sys.exit(1) swww_set(cur) def main() -> None: parser = argparse.ArgumentParser( description="Hyprland wallpaper helper (swww + wallhaven + favorites" ) sub = parser.add_subparsers(dest="cmd", required=True) p_random = sub.add_parser( "random", help="Set random wallpaper (favorites or download)" ) p_random.add_argument( "--fav-weight", type=float, default=0.3, help="Probability to pick from favorites (0..1). Default: 0.3", ) p_random.add_argument( "--tags", nargs="+", default=DEFAULT_TAGS, help=f"Wall haven tags list. Default: {DEFAULT_TAGS}", ) p_random.set_defaults(func=cmd_random) p_fav = sub.add_parser("favorite", help="Save current wallpaper to favorites") p_fav.set_defaults(func=cmd_favorite) p_fav = sub.add_parser("reload", help="Reload current wallpaper") p_fav.set_defaults(func=reload) args = parser.parse_args() args.func(args) if __name__ == "__main__": main()