- Use Web UI as authoritative time source for better MPRIS position sync - Implement album art fetching via iTunes Search API - Optimize window title parsing to focus on metadata - Add busctl testing commands to README and GEMINI.md
357 lines
14 KiB
Python
Executable File
357 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
import requests
|
||
import time
|
||
import re
|
||
import subprocess
|
||
from threading import Thread
|
||
from pydbus import SessionBus
|
||
from pydbus.generic import signal
|
||
from gi.repository import GLib
|
||
from bs4 import BeautifulSoup
|
||
|
||
# --- CONFIGURATION ---
|
||
BASE_URL = "http://localhost:5666"
|
||
AUTH = ('winamp', 'llama')
|
||
APP_ID = "org.mpris.MediaPlayer2.winamp"
|
||
DEFAULT_ART = "https://webamp.org/favicon.ico"
|
||
|
||
class WinampMPRIS:
|
||
"""
|
||
MPRIS2 specification implementation for Winamp Web Interface.
|
||
Optimized for Plasma 6 property change detection.
|
||
"""
|
||
dbus = f"""
|
||
<node>
|
||
<interface name="org.mpris.MediaPlayer2">
|
||
<property name="CanQuit" type="b" access="read" />
|
||
<property name="CanRaise" type="b" access="read" />
|
||
<property name="HasTrackList" type="b" access="read" />
|
||
<property name="Identity" type="s" access="read" />
|
||
<property name="DesktopEntry" type="s" access="read" />
|
||
<property name="SupportedUriSchemes" type="as" access="read" />
|
||
<property name="SupportedMimeTypes" type="as" access="read" />
|
||
</interface>
|
||
<interface name="org.mpris.MediaPlayer2.Player">
|
||
<method name="Next" />
|
||
<method name="Previous" />
|
||
<method name="Pause" />
|
||
<method name="PlayPause" />
|
||
<method name="Stop" />
|
||
<method name="Play" />
|
||
<method name="Seek">
|
||
<arg type="x" name="Offset" direction="in" />
|
||
</method>
|
||
<method name="SetPosition">
|
||
<arg type="o" name="TrackId" direction="in" />
|
||
<arg type="x" name="Position" direction="in" />
|
||
</method>
|
||
<method name="OpenUri">
|
||
<arg type="s" name="Uri" direction="in" />
|
||
</method>
|
||
<property name="PlaybackStatus" type="s" access="read">
|
||
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
|
||
</property>
|
||
<property name="LoopStatus" type="s" access="read" />
|
||
<property name="Rate" type="d" access="read" />
|
||
<property name="Shuffle" type="b" access="read" />
|
||
<property name="Metadata" type="a{{sv}}" access="read">
|
||
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
|
||
</property>
|
||
<property name="CanGoNext" type="b" access="read" />
|
||
<property name="CanGoPrevious" type="b" access="read" />
|
||
<property name="CanPlay" type="b" access="read" />
|
||
<property name="CanPause" type="b" access="read" />
|
||
<property name="CanControl" type="b" access="read" />
|
||
<property name="CanSeek" type="b" access="read" />
|
||
<property name="Position" type="x" access="read">
|
||
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="false"/>
|
||
</property>
|
||
<property name="Volume" type="d" access="read" />
|
||
</interface>
|
||
</node>
|
||
"""
|
||
|
||
# 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()
|
||
|
||
def _request(self, endpoint):
|
||
print(f"COMMAND RECEIVED: {endpoint}")
|
||
try:
|
||
r = requests.get(f"{BASE_URL}/{endpoint}", auth=AUTH, timeout=2)
|
||
if r.status_code != 200:
|
||
msg = f"Failed to send '{endpoint}' to Winamp (Status {r.status_code})."
|
||
print(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}"
|
||
print(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): pass
|
||
|
||
# --- 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 "None"
|
||
@property
|
||
def Rate(self): return 1.0
|
||
@property
|
||
def Shuffle(self): return False
|
||
@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 1.0
|
||
|
||
@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–<Dall>–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 = ""
|
||
offline_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
|
||
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
|
||
|
||
# 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 = ""
|
||
except requests.exceptions.RequestException:
|
||
if not window_title:
|
||
if not offline_logged:
|
||
print("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):
|
||
|
||
# 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
|
||
|
||
album_str = f" [{player._album}]" if player._album else ""
|
||
print(f"UPDATE: [{player._status}] {player._artist}{album_str} - {player._title}")
|
||
|
||
player.PropertiesChanged(
|
||
"org.mpris.MediaPlayer2.Player",
|
||
{
|
||
"PlaybackStatus": player.PlaybackStatus,
|
||
"Metadata": player.Metadata
|
||
},
|
||
[]
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"Update error: {e}")
|
||
|
||
time.sleep(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
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()
|
||
|
||
print(f"--- Winamp Bridge Started (Window Title + Web UI + Album Art) ---")
|
||
loop = GLib.MainLoop()
|
||
try:
|
||
loop.run()
|
||
except KeyboardInterrupt:
|
||
pass
|