#!/usr/bin/env python3 from __future__ import annotations import colorsys import json import math import os import re import subprocess import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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") CONTROL_HOST = os.environ.get("CONTROL_HOST", "0.0.0.0") CONTROL_PORT = int(os.environ.get("CONTROL_PORT", "8765")) CONTROL_TOKEN = os.environ.get("CONTROL_TOKEN", "") 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 class ControlState: def __init__(self) -> None: self._lock = threading.Lock() self._paused = False self._force_update = False def pause(self) -> None: with self._lock: self._paused = True def resume(self) -> None: with self._lock: self._paused = False self._force_update = True def toggle(self) -> bool: with self._lock: self._paused = not self._paused if not self._paused: self._force_update = True return self._paused def is_paused(self) -> bool: with self._lock: return self._paused def consume_force_update(self) -> bool: with self._lock: force_update = self._force_update self._force_update = False return force_update def snapshot(self) -> dict[str, object]: with self._lock: return { "paused": self._paused, "running": True, } def set_current(self, art_url: str, rgb: tuple[int, int, int], rgb2: tuple[int, int, int]) -> None: with self._lock: self._art_url = art_url self._rgb = rgb self._rgb2 = rgb2 def set_lyrics(self, lyrics: list[tuple[float, str]]) -> None: with self._lock: self._lyrics = lyrics def set_position(self, position: float) -> None: with self._lock: self._position = position def current(self) -> dict[str, object]: with self._lock: lyrics = getattr(self, "_lyrics", []) position = getattr(self, "_position", 0.0) MAX_DISPLAY = 5.0 active_idx = -1 line = "" for i, (ts, text) in enumerate(lyrics): if ts > position: break next_ts = lyrics[i + 1][0] if i + 1 < len(lyrics) else ts + MAX_DISPLAY if position <= min(next_ts, ts + MAX_DISPLAY): active_idx = i line = text else: active_idx = -1 line = "" # Build 5-line context from non-empty lyrics, centered on active context: list[dict[str, object]] = [] if active_idx >= 0 and line: display_idxs = [i for i, (_, t) in enumerate(lyrics) if t.strip()] if active_idx in display_idxs: pos = display_idxs.index(active_idx) for offset in range(-2, 3): di = pos + offset t = lyrics[display_idxs[di]][1] if 0 <= di < len(display_idxs) else "" context.append({"text": t, "offset": offset}) return { "art_url": getattr(self, "_art_url", None), "rgb": list(getattr(self, "_rgb", [0, 200, 255])), "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), "lyric_line": line, "lyric_context": context, } CONTROL_STATE = ControlState() class ControlHandler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: object) -> None: return def do_GET(self) -> None: if self.path == "/status": self.write_json(200, CONTROL_STATE.snapshot()) return if self.path == "/current": self.write_json(200, CONTROL_STATE.current()) return if self.path.startswith("/art"): art_url = CONTROL_STATE.current().get("art_url") if not art_url: self.write_json(404, {"error": "no art"}) return try: if art_url.startswith("file://"): body = open(art_url[7:], "rb").read() ctype = "image/png" if art_url.endswith(".png") else "image/jpeg" else: resp = requests.get(art_url, timeout=5) body = resp.content ctype = resp.headers.get("Content-Type", "image/jpeg") self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(body) except Exception: self.write_json(502, {"error": "fetch failed"}) return self.write_json(404, {"error": "not found"}) def do_POST(self) -> None: if not self.authorized(): self.write_json(401, {"error": "unauthorized"}) return if self.path == "/pause": CONTROL_STATE.pause() elif self.path == "/resume": CONTROL_STATE.resume() elif self.path == "/toggle": CONTROL_STATE.toggle() else: self.write_json(404, {"error": "not found"}) return self.write_json(200, CONTROL_STATE.snapshot()) def authorized(self) -> bool: if not CONTROL_TOKEN: return True header_token = self.headers.get("X-Music-Light-Token", "") bearer_token = self.headers.get("Authorization", "").removeprefix("Bearer ") return CONTROL_TOKEN in {header_token, bearer_token} def write_json(self, status: int, payload: dict[str, object]) -> None: body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(body) def start_control_server() -> ThreadingHTTPServer: server = ThreadingHTTPServer((CONTROL_HOST, CONTROL_PORT), ControlHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() print(f"Controle HTTP ouvindo em {CONTROL_HOST}:{CONTROL_PORT}") return server _LRC_TS = re.compile(r'\[(\d{1,2}):(\d{2}(?:\.\d+)?)\]') def parse_lrc(text: str) -> list[tuple[float, str]]: lines = [] for raw in text.splitlines(): m = _LRC_TS.match(raw) if not m: continue ts = int(m.group(1)) * 60 + float(m.group(2)) line = raw[m.end():].strip() lines.append((ts, line)) return lines _TITLE_JUNK = re.compile( r"[\s\-–]+(" r"\d{4}\s*remaster(ed)?|remaster(ed)?(\s+\d{4})?|" r"deluxe(\s+edition)?|expanded(\s+edition)?|" r"anniversary(\s+edition)?|bonus\s+track|" r"live(\s+version)?|acoustic(\s+version)?|" r"radio\s+edit|single\s+version|album\s+version" r")[\s\)\]]*$", re.IGNORECASE, ) def clean_title(title: str) -> str: # strip parenthesised/bracketed suffixes first, then dash-separated junk t = re.sub(r"\s*[\(\[][^\)\]]*[\)\]]$", "", title).strip() t = _TITLE_JUNK.sub("", t).strip() return t def fetch_lyrics(artist: str, title: str) -> list[tuple[float, str]]: if not artist or not title: return [] clean = clean_title(title) for t in ([clean, title] if clean != title else [title]): try: resp = requests.get( "https://lrclib.net/api/get", params={"artist_name": artist, "track_name": t}, timeout=20, ) if resp.status_code == 200: synced = resp.json().get("syncedLyrics") or "" if synced: return parse_lrc(synced) except Exception: pass return [] def fetch_lyrics_async(artist: str, title: str) -> None: def _run() -> None: lyrics = fetch_lyrics(artist, title) CONTROL_STATE.set_lyrics(lyrics) print(f"Letras: {len(lyrics)} linhas") threading.Thread(target=_run, daemon=True).start() def start_position_thread() -> threading.Thread: def _run() -> None: while True: try: player = choose_active_player() if player: result = subprocess.run( [PLAYERCTL_BIN, "--player", player, "position"], capture_output=True, text=True, timeout=1, ) if result.returncode == 0 and result.stdout.strip(): CONTROL_STATE.set_position(float(result.stdout.strip())) except Exception: pass time.sleep(0.5) t = threading.Thread(target=_run, daemon=True) t.start() return t 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 build_buckets(img: Image.Image) -> dict[tuple[int, int, int], int]: pixels = good_pixels(img) 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 return buckets def dominant_color(img: Image.Image) -> tuple[int, int, int]: pixels = good_pixels(img) if not pixels: return PURPLE_FALLBACK_RGB if should_use_purple_fallback(pixels): return PURPLE_FALLBACK_RGB buckets = build_buckets(img) best = max( buckets.items(), key=lambda item: item[1] * bucket_weight(item[0]), )[0] return boost_color(best) def secondary_color(img: Image.Image, primary: tuple[int, int, int]) -> tuple[int, int, int]: buckets = build_buckets(img) primary_h = rgb_to_hsv(primary)[0] MIN_HUE_DIST = 0.12 # at least ~43° apart ranked = sorted( buckets.items(), key=lambda item: item[1] * bucket_weight(item[0]), reverse=True, ) for bucket, _ in ranked: h = rgb_to_hsv(bucket)[0] if hue_distance(h, primary_h) >= MIN_HUE_DIST: return boost_color(bucket) # fallback: complementary hue of primary h, s, v = rgb_to_hsv(primary) return hsv_to_rgb((h + 0.5) % 1.0, max(s, 0.5), max(v, 0.5)) 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.") control_server = start_control_server() start_position_thread() last_track = "" last_rgb: tuple[int, int, int] | None = None while True: try: if CONTROL_STATE.is_paused(): time.sleep(2) continue 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 CONTROL_STATE.consume_force_update() or 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) rgb2 = secondary_color(img, rgb) print(f"Cor base: {raw_rgb} -> paleta: {palette_rgb}") print(f"Aplicando cor: {rgb} secundária: {rgb2}") set_light_color(rgb) CONTROL_STATE.set_current(art_url, rgb, rgb2) artist = run_playerctl("artist", player) title = run_playerctl("title", player) CONTROL_STATE.set_lyrics([]) fetch_lyrics_async(artist, title) last_track = track_id last_rgb = rgb time.sleep(2) except KeyboardInterrupt: print("\nEncerrado.") control_server.shutdown() control_server.server_close() break except Exception as exc: print(f"Erro: {exc}") time.sleep(3) if __name__ == "__main__": main()