Now runs automatically every 15 minutes, always updates to new backgrounds, and tries to reduce the blue light level at night
216 lines
5.9 KiB
Python
216 lines
5.9 KiB
Python
"""Script to update the wallpaper"""
|
|
from abc import ABC, abstractmethod
|
|
from json import dumps, loads
|
|
from math import ceil, floor, isnan, nan
|
|
from os import environ
|
|
from pathlib import Path
|
|
from random import sample
|
|
from re import Pattern, compile, match
|
|
from subprocess import run
|
|
from time import sleep
|
|
from typing import cast, override
|
|
|
|
from .evening import on_steam, sunup_amount
|
|
|
|
|
|
# Python sets aren't insertion-ordered, but dict keys are
|
|
type OrderedSet[T] = dict[T, None]
|
|
|
|
|
|
class Background(ABC):
|
|
_colour: Pattern[str] = compile(r"^[a-fA-F0-9]{6}$")
|
|
|
|
@abstractmethod
|
|
def blue_light(self) -> float: ...
|
|
|
|
@abstractmethod
|
|
@override
|
|
def __eq__(self, value: object, /) -> bool: ...
|
|
|
|
@abstractmethod
|
|
@override
|
|
def __hash__(self) -> int: ...
|
|
|
|
@abstractmethod
|
|
@override
|
|
def __str__(self) -> str: ...
|
|
|
|
@classmethod
|
|
def parse(cls, arg: str) -> "Background":
|
|
if match(cls._colour, arg):
|
|
return Colour(int(arg, 16))
|
|
else:
|
|
return Wallpaper(Path(arg))
|
|
|
|
|
|
class Wallpaper(Background):
|
|
_cache: Path = Path(environ["XDG_CACHE_HOME"]) / "wallpaper_blue_light.json"
|
|
_wallpapers: Path = Path("~/Pictures/wallpapers/").expanduser()
|
|
|
|
def __init__(self, image: Path) -> None:
|
|
self._path: Path = image
|
|
self._blue_light: float = nan
|
|
|
|
def _calculate_blue_light(self) -> float:
|
|
from PIL import Image
|
|
data = cast(list[int], Image.open(self._path).getdata(2))
|
|
return sum(data) / len(data)
|
|
|
|
@classmethod
|
|
def _read_cache(cls) -> dict[str, float]:
|
|
if cls._cache.exists():
|
|
if (text := cls._cache.read_text().strip()):
|
|
if isinstance((obj := loads(text)), dict):
|
|
return cast(dict[str, float], obj)
|
|
return {}
|
|
|
|
@classmethod
|
|
def _write_cache(cls, cache: dict[str, float]) -> None:
|
|
stringified = dumps(cache)
|
|
if len(stringified) != cls._cache.write_text(stringified):
|
|
raise Exception("Did not fully write blue light cache to file")
|
|
|
|
@classmethod
|
|
def available(cls) -> "OrderedSet[Background]":
|
|
cache = cls._read_cache()
|
|
modified = False
|
|
wallpapers = {str(path): Wallpaper(path) for path in cls._wallpapers.iterdir()}
|
|
for wallpaper in wallpapers.values():
|
|
cached = cache.get(str(wallpaper._path))
|
|
if cached is None or isnan(cached):
|
|
print("New file:", wallpaper._path)
|
|
cached = wallpaper._calculate_blue_light()
|
|
modified = True
|
|
cache[str(wallpaper._path)] = cached
|
|
wallpaper._blue_light = cached
|
|
if modified:
|
|
cache = {path: score for (path, score) in sorted(cache.items(), key=lambda pair: pair[1])}
|
|
cls._write_cache(cache)
|
|
return {wallpapers[path]: None for path in cache.keys()}
|
|
|
|
@override
|
|
def blue_light(self) -> float:
|
|
return self._blue_light
|
|
|
|
@override
|
|
def __eq__(self, value: object, /) -> bool:
|
|
if isinstance(value, Wallpaper):
|
|
return self._path == value._path
|
|
return False
|
|
|
|
@override
|
|
def __hash__(self) -> int:
|
|
return hash(self._path)
|
|
|
|
@override
|
|
def __str__(self) -> str:
|
|
return str(self._path)
|
|
|
|
@override
|
|
def __repr__(self) -> str:
|
|
return f"Wallpaper({repr(self._path)})"
|
|
|
|
|
|
class Colour(Background):
|
|
def __init__(self, value: int) -> None:
|
|
self._value: int = value
|
|
|
|
@override
|
|
def blue_light(self) -> float:
|
|
return self._value & 0xFF
|
|
|
|
@override
|
|
def __eq__(self, value: object, /) -> bool:
|
|
if isinstance(value, Colour):
|
|
return self._value == value._value
|
|
return False
|
|
|
|
@override
|
|
def __hash__(self) -> int:
|
|
return hash(self._value)
|
|
|
|
@override
|
|
def __str__(self) -> str:
|
|
return hex(self._value)
|
|
|
|
@override
|
|
def __repr__(self) -> str:
|
|
return f"Colour({hex(self._value)})"
|
|
|
|
|
|
def _convert_bytes(output: bytes) -> str:
|
|
return output.decode("utf-8").strip()
|
|
|
|
|
|
def _swww_cmd(*args: str) -> str:
|
|
output = run(["swww", *args], capture_output=True)
|
|
if output.stderr:
|
|
raise Exception(f"swww returned: '{_convert_bytes(output.stderr)}'")
|
|
return _convert_bytes(output.stdout)
|
|
|
|
|
|
class Monitor:
|
|
_query: Pattern[str] = compile(r"^([^:]+): (\d+)x(\d+), scale: ([\d.]+), currently displaying: (?:image|color): (.+)$")
|
|
_transition_args: list[str] = [
|
|
"--transition-type=wipe",
|
|
"--transition-angle=30",
|
|
"--transition-step=45",
|
|
"--transition-fps=120",
|
|
]
|
|
|
|
def __init__(self, line: str) -> None:
|
|
if not (values := match(self._query, line)):
|
|
raise Exception(f"Failed to extract information from: '{line}'")
|
|
self._name: str = values[1]
|
|
self._resolution: tuple[int, int] = (int(values[2]), int(values[3]))
|
|
self._scale: float = float(values[4])
|
|
self._background: Background = Background.parse(values[5])
|
|
|
|
def is_colour(self) -> bool:
|
|
return isinstance(self._background, Colour)
|
|
|
|
def set_wallpaper(self, background: Background) -> None:
|
|
self._background = background
|
|
_ = _swww_cmd("img", *self._transition_args, f"--outputs={self._name}", str(self._background))
|
|
|
|
def get_background(self) -> Background:
|
|
return self._background
|
|
|
|
@classmethod
|
|
def current(cls) -> "list[Monitor]":
|
|
return [Monitor(line) for line in _swww_cmd("query").splitlines()]
|
|
|
|
@override
|
|
def __eq__(self, value: object, /) -> bool:
|
|
if isinstance(value, Monitor):
|
|
return self._background == value._background
|
|
return False
|
|
|
|
@override
|
|
def __repr__(self) -> str:
|
|
return f"Monitor('{self._name}: {self._resolution[0]}x{self._resolution[1]}, scale: {self._scale}, currently displaying: {"color" if self.is_colour() else "image"}: {self._background}')"
|
|
|
|
@override
|
|
def __str__(self) -> str:
|
|
return f"{self._name}: {self._background}"
|
|
|
|
|
|
def select_wallpapers(available: list[Background]) -> list[Background]:
|
|
radius = len(available) / 3
|
|
centre = sunup_amount() * len(available)
|
|
return available[max(0, floor(centre - radius)): min(len(available) + 1, ceil(centre + radius))]
|
|
|
|
|
|
def main(check_steam: bool = False) -> None:
|
|
if check_steam and on_steam():
|
|
return
|
|
|
|
monitors = Monitor.current()
|
|
wallpapers = Wallpaper.available()
|
|
|
|
for monitor in monitors:
|
|
wallpapers.pop(monitor.get_background(), None)
|
|
|
|
for monitor, wallpaper in zip(monitors, sample(select_wallpapers(list(wallpapers.keys())), len(monitors))):
|
|
monitor.set_wallpaper(wallpaper)
|
|
sleep(1.25)
|