#!/usr/bin/env python3 import requests import time import re import subprocess import os import logging from threading import Thread from pydbus import SessionBus from pydbus.generic import signal from gi.repository import GLib from bs4 import BeautifulSoup # --- CONFIGURATION & XDG PATHS --- BASE_URL = "http://localhost:5666" AUTH = ('winamp', 'llama') APP_ID = "org.mpris.MediaPlayer2.winamp" DEFAULT_ART = "https://webamp.org/favicon.ico" # XDG Standard Paths XDG_STATE_HOME = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) XDG_RUNTIME_DIR = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") LOG_DIR = os.path.join(XDG_STATE_HOME, "winamp-mpris") LOG_FILE = os.path.join(LOG_DIR, "bridge.log") PID_FILE = os.path.join(XDG_RUNTIME_DIR, "winamp-mpris.pid") # Ensure log directory exists os.makedirs(LOG_DIR, exist_ok=True) # Configure Logging logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler() ] ) logger = logging.getLogger("winamp-mpris") def write_pid_file(): try: with open(PID_FILE, "w") as f: f.write(str(os.getpid())) except Exception as e: logger.error(f"Failed to write PID file: {e}") class WinampMPRIS: """ MPRIS2 specification implementation for Winamp Web Interface. Optimized for Plasma 6 property change detection. """ dbus = f""" """ # Signal for org.freedesktop.DBus.Properties.PropertiesChanged PropertiesChanged = signal() def __init__(self): self._title = "Winamp" self._artist = "Unknown" self._album = "" self._status = "Stopped" self._art_url = DEFAULT_ART self._last_position_us = 0 self._total_length_us = 0 self._last_update_ts = time.time() self._shuffle = False self._loop_status = "None" self._volume = 1.0 def _request(self, endpoint): logger.info(f"COMMAND RECEIVED: {endpoint}") try: r = requests.get(f"{BASE_URL}/{endpoint}", auth=AUTH, timeout=2) if r.status_code == 401: msg = "401 Unauthorized: Check gen_httpsrv.dll plugin config. Ensure user 'winamp' has correct password and 'Play' permissions in Users tab." logger.warning(f"ERROR: {msg}") subprocess.run(["notify-send", "-u", "critical", "-t", "10000", "Winamp Bridge Auth Error", msg]) elif r.status_code != 200: msg = f"Failed to send '{endpoint}' to Winamp (Status {r.status_code})." logger.error(f"ERROR: {msg}") subprocess.run(["notify-send", "-u", "critical", "-t", "3000", "Winamp Bridge Error", msg]) except Exception as e: msg = f"Connection error while sending '{endpoint}': {e}" logger.warning(f"ERROR: {msg}") subprocess.run(["notify-send", "-u", "critical", "-t", "3000", "Winamp Bridge Offline", msg]) # MPRIS Methods def Next(self): self._request("next") def Previous(self): self._request("prev") def Pause(self): self._request("pause") def PlayPause(self): self._request("pause") def Play(self): self._request("play") def Stop(self): self._request("stop") def Seek(self, offset): pass def SetPosition(self, track_id, position): pass def OpenUri(self, uri): # Found /url?p& in source code - plays immediately self._request(f"url?p&{uri}") # --- Root Properties --- @property def CanQuit(self): return False @property def CanRaise(self): return False @property def HasTrackList(self): return False @property def Identity(self): return "Winamp Web Bridge" @property def DesktopEntry(self): return "" @property def SupportedUriSchemes(self): return [] @property def SupportedMimeTypes(self): return [] # --- Player Properties --- @property def PlaybackStatus(self): return self._status @property def LoopStatus(self): return self._loop_status @LoopStatus.setter def LoopStatus(self, value): # MPRIS: "None", "Track", "Playlist" # Winamp Web Interface has "on" or "off" for Repeat (playlist-wide) # We'll map "Playlist" and "Track" both to Repeat ON. if value in ["Playlist", "Track"]: self._request("playmode?repeat=on") else: self._request("playmode?repeat=off") @property def Rate(self): return 1.0 @Rate.setter def Rate(self, value): pass @property def Shuffle(self): return self._shuffle @Shuffle.setter def Shuffle(self, value): if value: self._request("playmode?random=on") else: self._request("playmode?random=off") @property def CanGoNext(self): return True @property def CanGoPrevious(self): return True @property def CanPlay(self): return True @property def CanPause(self): return True @property def CanControl(self): return True @property def CanSeek(self): return False @property def Position(self): if self._status == "Playing": now = time.time() elapsed_us = int((now - self._last_update_ts) * 1e6) current_pos = self._last_position_us + elapsed_us if self._total_length_us > 0: return min(current_pos, self._total_length_us) return current_pos return self._last_position_us @property def Volume(self): return self._volume @Volume.setter def Volume(self, value): self._volume = max(0.0, min(1.0, value)) # Plugin source shows /vol?volume= accepts 0-10 vol_10 = int(self._volume * 10) self._request(f"vol?volume={vol_10}") @property def Metadata(self): # Metadata is a{sv}, so values MUST be wrapped in GLib.Variant return { 'mpris:trackid': GLib.Variant('o', '/org/mpris/MediaPlayer2/winamp/track/0'), 'mpris:length': GLib.Variant('x', self._total_length_us), 'xesam:title': GLib.Variant('s', self._title), 'xesam:artist': GLib.Variant('as', [self._artist]), 'xesam:album': GLib.Variant('s', self._album), 'mpris:artUrl': GLib.Variant('s', self._art_url) } def parse_time_to_us(time_str): """Parses MM:SS or H:MM:SS to microseconds.""" try: parts = list(map(int, time_str.split(':'))) if len(parts) == 2: # MM:SS return (parts[0] * 60 + parts[1]) * 1_000_000 elif len(parts) == 3: # H:MM:SS return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1_000_000 except Exception: pass return 0 def get_winamp_window_title(): """Extracts the Winamp window title using wmctrl.""" try: output = subprocess.check_output(["wmctrl", "-l"], stderr=subprocess.STDOUT).decode('utf-8') for line in output.splitlines(): # Match the typical Winamp window title pattern if " - Winamp" in line: # wmctrl output: 0x03000001 0 Compy-686 ARTMS––Unf/Air | 0:06/2:45 - Winamp parts = line.split(None, 3) if len(parts) >= 4: return parts[3] except Exception: pass return None def fetch_album_art(artist, album): """Fetches album art from iTunes Search API.""" if not artist or not album or artist == "Unknown": return DEFAULT_ART try: query = f"{artist} {album}" url = f"https://itunes.apple.com/search?term={query}&entity=album&limit=1" r = requests.get(url, timeout=3) if r.status_code == 200: data = r.json() if data.get("resultCount", 0) > 0: art_100 = data["results"][0].get("artworkUrl100", DEFAULT_ART) # Upgrade to 600x600 for better quality return art_100.replace("100x100bb.jpg", "600x600bb.jpg") except Exception as e: print(f"Art fetch error: {e}") return DEFAULT_ART def update_loop(player): last_known_title = "" last_known_status = "" last_known_album = "" last_time_str = "" last_known_shuffle = False last_known_loop = "None" offline_logged = False auth_error_logged = False while True: try: # 1. Try Window Title (Primary) window_title = get_winamp_window_title() title_parsed = False if window_title: # Pattern: Artist–Album–Title | 0:06/2:45 - Winamp # Only use window title for metadata as time is unreliable here match = re.search(r"(.*?) \| .* - Winamp", window_title) if match: metadata_raw = match.group(1).strip() # Split metadata (Artist–Album–Title) meta_parts = [p.strip() for p in re.split(r'[–—-]', metadata_raw)] if len(meta_parts) >= 3: player._artist = meta_parts[0] player._album = meta_parts[1] player._title = " – ".join(meta_parts[2:]) elif len(meta_parts) == 2: player._artist = meta_parts[0] player._title = meta_parts[1] player._album = "" else: player._title = metadata_raw player._artist = "Unknown" player._album = "" title_parsed = True # 2. Poll Web UI (Status and Time source) try: r = requests.get(f"{BASE_URL}/main", auth=AUTH, timeout=1) if r.status_code == 200: offline_logged = False auth_error_logged = False soup = BeautifulSoup(r.text, 'html.parser') p_tags = soup.find_all('p') status_raw = p_tags[0].text if p_tags else "" new_status = "Stopped" if "Playing" in status_raw: new_status = "Playing" elif "Paused" in status_raw: new_status = "Paused" player._status = new_status # Parse Repeat and Random status #

Repeat is off ...
Random is off ...

repeat_tag = None for p in p_tags: if "Repeat is" in p.text: repeat_tag = p break if repeat_tag: # Extract "on" or "off" status text = repeat_tag.get_text() is_repeat = False is_random = False m_rep = re.search(r"Repeat is\s*(?:)?(\w+)(?:)?", text, re.I) if m_rep: is_repeat = m_rep.group(1).lower() == "on" m_rand = re.search(r"Random is\s*(?:)?(\w+)(?:)?", text, re.I) if m_rand: is_random = m_rand.group(1).lower() == "on" player._loop_status = "Playlist" if is_repeat else "None" player._shuffle = is_random # Always update time from Web UI match_time = re.search(r"\((\d+:?\d*:\d+) / (\d+:?\d*:\d+)\)", status_raw) if match_time: current_time_str = match_time.group(1) if current_time_str != last_time_str: player._last_position_us = parse_time_to_us(current_time_str) player._total_length_us = parse_time_to_us(match_time.group(2)) player._last_update_ts = time.time() last_time_str = current_time_str if not title_parsed: # Web UI Fallback for metadata match_meta = re.search(r"track \d+ - (.*?) - (.*?) -", status_raw) if match_meta: player._artist = match_meta.group(1).strip() player._title = match_meta.group(2).strip() player._album = "" elif "track" in status_raw: player._title = status_raw.split('-')[0].replace("Playing track ", "").strip() player._artist = "Unknown" player._album = "" elif r.status_code == 401: if not auth_error_logged: msg = "401 Unauthorized: Check gen_httpsrv.dll plugin config. Ensure user 'winamp' has correct password and 'Play' permissions in Users tab." logger.warning(f"ERROR: {msg}") subprocess.run(["notify-send", "-u", "critical", "-t", "10000", "Winamp Bridge Auth Error", msg]) auth_error_logged = True player._status = "Stopped" except requests.exceptions.RequestException: if not window_title: if not offline_logged: logger.info("Winamp Web Interface offline and no window found.") offline_logged = True player._status = "Stopped" if (player._status != last_known_status or player._title != last_known_title or player._album != last_known_album or player._shuffle != last_known_shuffle or player._loop_status != last_known_loop): # Fetch art if album/artist changed if player._artist != "Unknown" and player._album: player._art_url = fetch_album_art(player._artist, player._album) else: player._art_url = DEFAULT_ART last_known_status = player._status last_known_title = player._title last_known_album = player._album last_known_shuffle = player._shuffle last_known_loop = player._loop_status album_str = f" [{player._album}]" if player._album else "" logger.info(f"UPDATE: [{player._status}] Shuffle: {player._shuffle}, Loop: {player._loop_status}, {player._artist}{album_str} - {player._title}") player.PropertiesChanged( "org.mpris.MediaPlayer2.Player", { "PlaybackStatus": player.PlaybackStatus, "Metadata": player.Metadata, "Shuffle": player.Shuffle, "LoopStatus": player.LoopStatus, "Volume": player.Volume }, [] ) except Exception as e: logger.error(f"Update error: {e}") time.sleep(1) if __name__ == "__main__": write_pid_file() bus = SessionBus() player_logic = WinampMPRIS() bus.publish(APP_ID, ("/org/mpris/MediaPlayer2", player_logic)) GLib.timeout_add(1500, lambda: player_logic.PropertiesChanged( "org.mpris.MediaPlayer2.Player", { "PlaybackStatus": player_logic.PlaybackStatus, "Metadata": player_logic.Metadata }, [] )) thread = Thread(target=update_loop, args=(player_logic,), daemon=True) thread.start() logger.info(f"--- Winamp Bridge Started (Window Title + Web UI + Album Art) ---") loop = GLib.MainLoop() try: loop.run() except KeyboardInterrupt: pass