Fix position synchronization, add album art fetching, and document busctl usage

- 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
This commit is contained in:
2026-03-19 05:31:11 -04:00
parent 21afe7650a
commit 7fa164f462
3 changed files with 89 additions and 18 deletions

View File

@@ -59,6 +59,25 @@ A systemd service file is provided to allow the bridge to run automatically in t
The bridge will publish itself on D-Bus as `org.mpris.MediaPlayer2.winamp`. It gracefully handles Winamp being offline, automatically reconnecting when the web interface becomes available. The bridge will publish itself on D-Bus as `org.mpris.MediaPlayer2.winamp`. It gracefully handles Winamp being offline, automatically reconnecting when the web interface becomes available.
### Testing and Debugging
You can use `busctl` to interact with the MPRIS interface:
```bash
# Get playback status
busctl --user get-property org.mpris.MediaPlayer2.winamp /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player PlaybackStatus
# Get current position (in microseconds)
busctl --user get-property org.mpris.MediaPlayer2.winamp /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player Position
# Get metadata
busctl --user get-property org.mpris.MediaPlayer2.winamp /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player Metadata
# Send commands
busctl --user call org.mpris.MediaPlayer2.winamp /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player PlayPause
busctl --user call org.mpris.MediaPlayer2.winamp /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player Next
```
## Key Files ## Key Files
- **`winamp_mpris.py`**: The main bridge script. Includes optimizations for Plasma 6 property change detection, live position tracking, and robust error handling. - **`winamp_mpris.py`**: The main bridge script. Includes optimizations for Plasma 6 property change detection, live position tracking, and robust error handling.

View File

@@ -51,6 +51,19 @@ sudo dnf install wmctrl libnotify # or sudo apt install wmctrl libnotify
python3 winamp_mpris.py python3 winamp_mpris.py
``` ```
### Testing and Debugging
You can use `busctl` to interact with the MPRIS interface:
```bash
# Get playback status
busctl --user get-property org.mpris.MediaPlayer2.winamp /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player PlaybackStatus
# Get current position (in microseconds)
busctl --user get-property org.mpris.MediaPlayer2.winamp /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player Position
# Send commands
busctl --user call org.mpris.MediaPlayer2.winamp /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player PlayPause
```
### Running as a systemd User Service ### Running as a systemd User Service
1. **Install the service file**: 1. **Install the service file**:
```bash ```bash

View File

@@ -13,6 +13,7 @@ from bs4 import BeautifulSoup
BASE_URL = "http://localhost:5666" BASE_URL = "http://localhost:5666"
AUTH = ('winamp', 'llama') AUTH = ('winamp', 'llama')
APP_ID = "org.mpris.MediaPlayer2.winamp" APP_ID = "org.mpris.MediaPlayer2.winamp"
DEFAULT_ART = "https://webamp.org/favicon.ico"
class WinampMPRIS: class WinampMPRIS:
""" """
@@ -78,6 +79,7 @@ class WinampMPRIS:
self._artist = "Unknown" self._artist = "Unknown"
self._album = "" self._album = ""
self._status = "Stopped" self._status = "Stopped"
self._art_url = DEFAULT_ART
self._last_position_us = 0 self._last_position_us = 0
self._total_length_us = 0 self._total_length_us = 0
self._last_update_ts = time.time() self._last_update_ts = time.time()
@@ -147,7 +149,8 @@ class WinampMPRIS:
@property @property
def Position(self): def Position(self):
if self._status == "Playing": if self._status == "Playing":
elapsed_us = int((time.time() - self._last_update_ts) * 1e6) now = time.time()
elapsed_us = int((now - self._last_update_ts) * 1e6)
current_pos = self._last_position_us + elapsed_us current_pos = self._last_position_us + elapsed_us
if self._total_length_us > 0: if self._total_length_us > 0:
return min(current_pos, self._total_length_us) return min(current_pos, self._total_length_us)
@@ -166,7 +169,7 @@ class WinampMPRIS:
'xesam:title': GLib.Variant('s', self._title), 'xesam:title': GLib.Variant('s', self._title),
'xesam:artist': GLib.Variant('as', [self._artist]), 'xesam:artist': GLib.Variant('as', [self._artist]),
'xesam:album': GLib.Variant('s', self._album), 'xesam:album': GLib.Variant('s', self._album),
'mpris:artUrl': GLib.Variant('s', 'https://webamp.org/favicon.ico') 'mpris:artUrl': GLib.Variant('s', self._art_url)
} }
def parse_time_to_us(time_str): def parse_time_to_us(time_str):
@@ -196,9 +199,30 @@ def get_winamp_window_title():
pass pass
return None 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): def update_loop(player):
last_known_title = "" last_known_title = ""
last_known_status = "" last_known_status = ""
last_known_album = ""
last_time_str = ""
offline_logged = False offline_logged = False
while True: while True:
@@ -209,11 +233,10 @@ def update_loop(player):
if window_title: if window_title:
# Pattern: ArtistAlbumTitle | 0:06/2:45 - Winamp # Pattern: ArtistAlbumTitle | 0:06/2:45 - Winamp
match = re.search(r"(.*?) \| (\d+:?\d*:\d*)/(\d+:?\d*:\d*) - Winamp", window_title) # Only use window title for metadata as time is unreliable here
match = re.search(r"(.*?) \| .* - Winamp", window_title)
if match: if match:
metadata_raw = match.group(1).strip() metadata_raw = match.group(1).strip()
new_pos_us = parse_time_to_us(match.group(2))
new_len_us = parse_time_to_us(match.group(3))
# Split metadata (ArtistAlbumTitle) # Split metadata (ArtistAlbumTitle)
meta_parts = [p.strip() for p in re.split(r'[–—-]', metadata_raw)] meta_parts = [p.strip() for p in re.split(r'[–—-]', metadata_raw)]
@@ -231,12 +254,9 @@ def update_loop(player):
player._artist = "Unknown" player._artist = "Unknown"
player._album = "" player._album = ""
player._last_position_us = new_pos_us
player._total_length_us = new_len_us
player._last_update_ts = time.time()
title_parsed = True title_parsed = True
# 2. Poll Web UI (Fallback and Controls/Status) # 2. Poll Web UI (Status and Time source)
try: try:
r = requests.get(f"{BASE_URL}/main", auth=AUTH, timeout=1) r = requests.get(f"{BASE_URL}/main", auth=AUTH, timeout=1)
if r.status_code == 200: if r.status_code == 200:
@@ -251,20 +271,27 @@ def update_loop(player):
player._status = new_status 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: if not title_parsed:
# Web UI Fallback for metadata # Web UI Fallback for metadata
match_meta = re.search(r"track \d+ - (.*?) - (.*?) -", status_raw) match_meta = re.search(r"track \d+ - (.*?) - (.*?) -", status_raw)
if match_meta: if match_meta:
player._artist = match_meta.group(1).strip() player._artist = match_meta.group(1).strip()
player._title = match_meta.group(2).strip() player._title = match_meta.group(2).strip()
player._album = ""
elif "track" in status_raw: elif "track" in status_raw:
player._title = status_raw.split('-')[0].replace("Playing track ", "").strip() player._title = status_raw.split('-')[0].replace("Playing track ", "").strip()
player._artist = "Unknown"
match_time = re.search(r"\((\d+:?\d*:\d+) / (\d+:?\d*:\d+)\)", status_raw) player._album = ""
if match_time:
player._last_position_us = parse_time_to_us(match_time.group(1))
player._total_length_us = parse_time_to_us(match_time.group(2))
player._last_update_ts = time.time()
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
if not window_title: if not window_title:
if not offline_logged: if not offline_logged:
@@ -272,11 +299,22 @@ def update_loop(player):
offline_logged = True offline_logged = True
player._status = "Stopped" player._status = "Stopped"
if player._status != last_known_status or player._title != last_known_title: 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_status = player._status
last_known_title = player._title last_known_title = player._title
last_known_album = player._album
print(f"UPDATE: [{player._status}] {player._artist} - {player._title}") album_str = f" [{player._album}]" if player._album else ""
print(f"UPDATE: [{player._status}] {player._artist}{album_str} - {player._title}")
player.PropertiesChanged( player.PropertiesChanged(
"org.mpris.MediaPlayer2.Player", "org.mpris.MediaPlayer2.Player",
@@ -292,6 +330,7 @@ def update_loop(player):
time.sleep(1) time.sleep(1)
if __name__ == "__main__": if __name__ == "__main__":
bus = SessionBus() bus = SessionBus()
player_logic = WinampMPRIS() player_logic = WinampMPRIS()
@@ -309,7 +348,7 @@ if __name__ == "__main__":
thread = Thread(target=update_loop, args=(player_logic,), daemon=True) thread = Thread(target=update_loop, args=(player_logic,), daemon=True)
thread.start() thread.start()
print(f"--- Winamp Bridge Started (Window Title + Web UI) ---") print(f"--- Winamp Bridge Started (Window Title + Web UI + Album Art) ---")
loop = GLib.MainLoop() loop = GLib.MainLoop()
try: try:
loop.run() loop.run()