Files
winamp-mpris/winamp_mpris.py
ergosteur 1e9257a27f Add Volume control and OpenUri support
- Implement MPRIS Volume property (readwrite) with 0-10 mapping for Winamp
- Implement MPRIS OpenUri method using the discovered /url?p& endpoint
- Add Volume to PropertiesChanged signal notifications
- Update D-Bus XML interface definition
2026-03-31 12:50:19 -04:00

429 lines
16 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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="readwrite">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="Rate" type="d" access="readwrite" />
<property name="Shuffle" type="b" access="readwrite">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<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="readwrite">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
</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()
self._shuffle = False
self._loop_status = "None"
self._volume = 1.0
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):
# Found /url?p&<url> 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<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 = ""
last_known_shuffle = False
last_known_loop = "None"
offline_logged = False
while True:
try:
# 1. Try Window Title (Primary)
window_title = get_winamp_window_title()
title_parsed = False
if window_title:
# Pattern: ArtistAlbumTitle | 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 (ArtistAlbumTitle)
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
# Parse Repeat and Random status
# <p>Repeat is <b>off</b> ... <br>Random is <b>off</b> ... </p>
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*(?:<b>)?(\w+)(?:</b>)?", text, re.I)
if m_rep:
is_repeat = m_rep.group(1).lower() == "on"
m_rand = re.search(r"Random is\s*(?:<b>)?(\w+)(?:</b>)?", 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 = ""
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 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 ""
print(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:
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