#!/usr/bin/env python3 from __future__ import annotations import colorsys import math import os import subprocess import time from io import BytesIO from urllib.parse import parse_qs, urlparse import requests from PIL import Image HA_URL = os.environ.get("HA_URL", "http://127.0.0.1:8123") HA_TOKEN = os.environ.get( "HA_TOKEN", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkZjM1MmRhNjg1ZWM0MGJiYTc4YTZlNWY3ZmQ0YTA1YSIsImlhdCI6MTc3NjI4MTI0MiwiZXhwIjoyMDkxNjQxMjQyfQ.rR0Kfwk5Smr06_9KJSDn3YPXiRlokEZZFiAa_naNs78", ) LIGHT_ENTITY = os.environ.get("LIGHT_ENTITY", "light.luzsala_luz_sala") # Ajustes visuais BRIGHTNESS_PCT = 70 SATURATION_BOOST = 1.15 MIN_SATURATION = 0.20 MIN_VALUE = 0.18 REQUEST_TIMEOUT = 10 MAX_VALUE = 0.82 PALETTE_SIZE = 32 OKLCH_LIGHTNESS = 0.72 OKLCH_CHROMA = 0.24 MAX_BRIGHTNESS_STEP = 0.10 PLAYERCTL_BIN = os.environ.get("PLAYERCTL_BIN", "playerctl") YELLOW_HUE_START = 0.12 YELLOW_HUE_END = 0.18 AMBER_TARGET_HUE = 0.10 YELLOW_CHROMA_SCALE = 0.82 YELLOW_LIGHTNESS_SCALE = 0.94 NEUTRAL_CHROMA_THRESHOLD = 0.05 NEUTRAL_SATURATION_THRESHOLD = 0.18 PURPLE_FALLBACK_HUE = 0.78 def run_playerctl(field: str, player: str | None = None) -> str: command = [PLAYERCTL_BIN] if player: command.extend(["--player", player]) command.extend(["metadata", field]) result = subprocess.run( command, capture_output=True, text=True, ) if result.returncode != 0: return "" return result.stdout.strip() def list_players() -> list[str]: result = subprocess.run( [PLAYERCTL_BIN, "-l"], capture_output=True, text=True, ) if result.returncode != 0: return [] return [line.strip() for line in result.stdout.splitlines() if line.strip()] def player_status(player: str) -> str: result = subprocess.run( [PLAYERCTL_BIN, "--player", player, "status"], capture_output=True, text=True, ) if result.returncode != 0: return "" return result.stdout.strip() def choose_active_player() -> str: for player in list_players(): if player_status(player) == "Playing": return player return "" def get_track_url(player: str) -> str: return run_playerctl("xesam:url", player) def get_player_name(player: str) -> str: result = subprocess.run( [PLAYERCTL_BIN, "--player", player, "metadata", "--format", "{{playerName}}"], capture_output=True, text=True, ) if result.returncode != 0: return player return result.stdout.strip() or player def youtube_video_id(url: str) -> str: if not url: return "" parsed = urlparse(url) host = parsed.netloc.lower() path = parsed.path.strip("/") if host in {"youtu.be", "www.youtu.be"}: return path.split("/", 1)[0] if "youtube.com" not in host: return "" if path == "watch": return parse_qs(parsed.query).get("v", [""])[0] parts = path.split("/") if len(parts) >= 2 and parts[0] in {"shorts", "live", "embed", "v"}: return parts[1] return "" def youtube_thumbnail_url(player: str) -> str: video_id = youtube_video_id(get_track_url(player)) if not video_id: return "" return f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" def get_current_track_id(player: str) -> str: title = run_playerctl("title", player) artist = run_playerctl("artist", player) art = run_playerctl("mpris:artUrl", player) url = get_track_url(player) player_name = get_player_name(player) return f"{player_name}::{artist}::{title}::{art or url}" def get_album_art_url(player: str) -> str: art_url = run_playerctl("mpris:artUrl", player) if art_url: return art_url track_url = get_track_url(player) if youtube_video_id(track_url): thumb_url = youtube_thumbnail_url(player) if thumb_url: print(f"Usando thumbnail do YouTube: {thumb_url}") return thumb_url return "" def load_image_from_url(url: str) -> Image.Image: if url.startswith("file://"): local_path = url[7:] return Image.open(local_path).convert("RGB") resp = requests.get(url, timeout=REQUEST_TIMEOUT) resp.raise_for_status() return Image.open(BytesIO(resp.content)).convert("RGB") def image_pixels(img: Image.Image) -> list[tuple[int, int, int]]: if hasattr(img, "get_flattened_data"): return list(img.get_flattened_data()) return list(img.getdata()) def hue_distance(a: float, b: float) -> float: diff = abs(a - b) return min(diff, 1.0 - diff) def rgb_to_hsv(rgb: tuple[int, int, int]) -> tuple[float, float, float]: r, g, b = rgb return colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) def hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]: r, g, b = colorsys.hsv_to_rgb(h, s, v) return (int(r * 255), int(g * 255), int(b * 255)) def tame_yellow_oklch(lightness: float, chroma: float, hue: float) -> tuple[float, float, float]: if YELLOW_HUE_START <= hue <= YELLOW_HUE_END: return ( lightness * YELLOW_LIGHTNESS_SCALE, chroma * YELLOW_CHROMA_SCALE, AMBER_TARGET_HUE, ) return (lightness, chroma, hue) def srgb_channel_to_linear(channel: float) -> float: if channel <= 0.04045: return channel / 12.92 return ((channel + 0.055) / 1.055) ** 2.4 def linear_channel_to_srgb(channel: float) -> float: if channel <= 0.0031308: return 12.92 * channel return 1.055 * (channel ** (1 / 2.4)) - 0.055 def rgb_to_oklab(rgb: tuple[int, int, int]) -> tuple[float, float, float]: r, g, b = rgb rl = srgb_channel_to_linear(r / 255.0) gl = srgb_channel_to_linear(g / 255.0) bl = srgb_channel_to_linear(b / 255.0) l = 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl m = 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl s = 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl l_ = l ** (1 / 3) m_ = m ** (1 / 3) s_ = s ** (1 / 3) return ( 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, ) def oklab_to_rgb(lightness: float, a: float, b: float) -> tuple[int, int, int]: l_ = lightness + 0.3963377774 * a + 0.2158037573 * b m_ = lightness - 0.1055613458 * a - 0.0638541728 * b s_ = lightness - 0.0894841775 * a - 1.2914855480 * b l = l_ ** 3 m = m_ ** 3 s = s_ ** 3 rl = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s gl = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s r = min(1.0, max(0.0, linear_channel_to_srgb(rl))) g = min(1.0, max(0.0, linear_channel_to_srgb(gl))) b = min(1.0, max(0.0, linear_channel_to_srgb(bl))) return (int(r * 255), int(g * 255), int(b * 255)) def rgb_to_oklch(rgb: tuple[int, int, int]) -> tuple[float, float, float]: lightness, a, b = rgb_to_oklab(rgb) chroma = (a * a + b * b) ** 0.5 hue = (0.0 if chroma == 0 else (math.atan2(b, a) / (2 * math.pi)) % 1.0) return (lightness, chroma, hue) def oklch_to_rgb(lightness: float, chroma: float, hue: float) -> tuple[int, int, int]: angle = 2 * math.pi * hue a = chroma * math.cos(angle) b = chroma * math.sin(angle) return oklab_to_rgb(lightness, a, b) def pure_tone_palette() -> list[tuple[int, int, int]]: return [ oklch_to_rgb(*tame_yellow_oklch(OKLCH_LIGHTNESS, OKLCH_CHROMA, index / PALETTE_SIZE)) for index in range(PALETTE_SIZE) ] PURE_TONE_PALETTE = pure_tone_palette() PURPLE_FALLBACK_RGB = oklch_to_rgb(OKLCH_LIGHTNESS, OKLCH_CHROMA, PURPLE_FALLBACK_HUE) def clamp_brightness(rgb: tuple[int, int, int], previous_rgb: tuple[int, int, int] | None) -> tuple[int, int, int]: if previous_rgb is None: return rgb h, s, v = rgb_to_hsv(rgb) _, _, prev_v = rgb_to_hsv(previous_rgb) limited_v = max(prev_v - MAX_BRIGHTNESS_STEP, min(prev_v + MAX_BRIGHTNESS_STEP, v)) return hsv_to_rgb(h, s, limited_v) def snap_to_palette(rgb: tuple[int, int, int]) -> tuple[int, int, int]: _, _, hue = rgb_to_oklch(rgb) best = min( PURE_TONE_PALETTE, key=lambda candidate: hue_distance(hue, rgb_to_oklch(candidate)[2]), ) best_lightness, _, best_hue = rgb_to_oklch(best) return oklch_to_rgb(*tame_yellow_oklch(best_lightness, OKLCH_CHROMA, best_hue)) def good_pixels(img: Image.Image) -> list[tuple[int, int, int]]: img = img.resize((80, 80)) pixels = image_pixels(img) filtered: list[tuple[int, int, int]] = [] for r, g, b in pixels: rf, gf, bf = r / 255.0, g / 255.0, b / 255.0 h, s, v = colorsys.rgb_to_hsv(rf, gf, bf) # ignora pixels muito escuros e quase cinza if v < MIN_VALUE: continue if s < MIN_SATURATION: continue if v > MAX_VALUE and s < 0.45: continue filtered.append((r, g, b)) return filtered def color_information_score(pixels: list[tuple[int, int, int]]) -> tuple[float, float]: if not pixels: return (0.0, 0.0) chroma_total = 0.0 saturation_total = 0.0 for pixel in pixels: _, chroma, _ = rgb_to_oklch(pixel) _, saturation, _ = rgb_to_hsv(pixel) chroma_total += chroma saturation_total += saturation count = len(pixels) return (chroma_total / count, saturation_total / count) def should_use_purple_fallback(pixels: list[tuple[int, int, int]]) -> bool: avg_chroma, avg_saturation = color_information_score(pixels) return avg_chroma < NEUTRAL_CHROMA_THRESHOLD or avg_saturation < NEUTRAL_SATURATION_THRESHOLD def dominant_color(img: Image.Image) -> tuple[int, int, int]: pixels = good_pixels(img) if not pixels: # fallback simples return PURPLE_FALLBACK_RGB if should_use_purple_fallback(pixels): return PURPLE_FALLBACK_RGB # Quantiza para agrupar tons parecidos buckets: dict[tuple[int, int, int], int] = {} for r, g, b in pixels: key = (r // 16 * 16, g // 16 * 16, b // 16 * 16) buckets[key] = buckets.get(key, 0) + 1 best = max( buckets.items(), key=lambda item: item[1] * bucket_weight(item[0]), )[0] return boost_color(best) def bucket_weight(rgb: tuple[int, int, int]) -> float: _, saturation, value = rgb_to_hsv(rgb) saturation_score = 0.6 + saturation brightness_midpoint = 0.62 brightness_penalty = abs(value - brightness_midpoint) * 1.2 return max(0.2, saturation_score - brightness_penalty) def boost_color(rgb: tuple[int, int, int]) -> tuple[int, int, int]: h, s, v = rgb_to_hsv(rgb) s = min(1.0, s * SATURATION_BOOST) v = min(0.74, max(v, 0.48)) return hsv_to_rgb(h, s, v) def set_light_color(rgb: tuple[int, int, int]) -> None: response = requests.post( f"{HA_URL}/api/services/light/turn_on", headers={ "Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json", }, json={ "entity_id": LIGHT_ENTITY, "rgb_color": list(rgb), "brightness_pct": BRIGHTNESS_PCT, }, timeout=REQUEST_TIMEOUT, ) response.raise_for_status() def main() -> None: print("Monitorando música atual. Ctrl+C para sair.") last_track = "" last_rgb: tuple[int, int, int] | None = None while True: try: player = choose_active_player() if not player: time.sleep(2) continue track_id = get_current_track_id(player) if not track_id or track_id == "::": time.sleep(2) continue if track_id != last_track: art_url = get_album_art_url(player) if not art_url: print("Sem capa disponível para a faixa atual.") last_track = track_id time.sleep(2) continue print(f"Nova faixa detectada: {track_id}") img = load_image_from_url(art_url) raw_rgb = dominant_color(img) palette_rgb = snap_to_palette(raw_rgb) rgb = clamp_brightness(palette_rgb, last_rgb) print(f"Cor base: {raw_rgb} -> paleta: {palette_rgb}") print(f"Aplicando cor: {rgb}") set_light_color(rgb) last_track = track_id last_rgb = rgb time.sleep(2) except KeyboardInterrupt: print("\nEncerrado.") break except Exception as exc: print(f"Erro: {exc}") time.sleep(3) if __name__ == "__main__": main()