Add systemd user service and enhance offline robustness
This commit is contained in:
28
GEMINI.md
28
GEMINI.md
@@ -30,17 +30,39 @@ You also need the `Winamp Web Interface` plugin installed and running in Winamp,
|
|||||||
|
|
||||||
### Running the Bridge
|
### Running the Bridge
|
||||||
|
|
||||||
To start the bridge, run:
|
### Manual Run
|
||||||
|
|
||||||
|
To start the bridge manually, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 winamp_mpris.py
|
python3 winamp_mpris.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The bridge will publish itself on D-Bus as `org.mpris.MediaPlayer2.winamp`.
|
### Running as a systemd User Service
|
||||||
|
|
||||||
|
A systemd service file is provided to allow the bridge to run automatically in the background.
|
||||||
|
|
||||||
|
1. **Install the service file**:
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/systemd/user/
|
||||||
|
cp winamp-mpris.service ~/.config/systemd/user/
|
||||||
|
```
|
||||||
|
2. **Reload systemd and enable the service**:
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now winamp-mpris.service
|
||||||
|
```
|
||||||
|
3. **Check the status**:
|
||||||
|
```bash
|
||||||
|
systemctl --user status winamp-mpris.service
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
- **`winamp_mpris.py`**: The main, active script. Includes optimizations for Plasma 6 property change detection and correct D-Bus signal emission.
|
- **`winamp_mpris.py`**: The main bridge script. Includes optimizations for Plasma 6 property change detection, live position tracking, and robust error handling.
|
||||||
|
- **`winamp-mpris.service`**: Systemd user service unit file.
|
||||||
- **`winamp_mpris_old.py`**: A previous iteration of the bridge.
|
- **`winamp_mpris_old.py`**: A previous iteration of the bridge.
|
||||||
- **`sample_Winamp Web Interface.html`**: A sample of the HTML returned by the Winamp Web Interface, used for reference in parsing logic.
|
- **`sample_Winamp Web Interface.html`**: A sample of the HTML returned by the Winamp Web Interface, used for reference in parsing logic.
|
||||||
|
|
||||||
|
|||||||
12
winamp-mpris.service
Normal file
12
winamp-mpris.service
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Winamp MPRIS Bridge
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 %h/Scripts/winamp-mpris/winamp_mpris.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -61,7 +61,9 @@ class WinampMPRIS:
|
|||||||
<property name="CanPause" type="b" access="read" />
|
<property name="CanPause" type="b" access="read" />
|
||||||
<property name="CanControl" type="b" access="read" />
|
<property name="CanControl" type="b" access="read" />
|
||||||
<property name="CanSeek" type="b" access="read" />
|
<property name="CanSeek" type="b" access="read" />
|
||||||
<property name="Position" type="x" 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" />
|
<property name="Volume" type="d" access="read" />
|
||||||
</interface>
|
</interface>
|
||||||
</node>
|
</node>
|
||||||
@@ -74,6 +76,9 @@ class WinampMPRIS:
|
|||||||
self._title = "Winamp"
|
self._title = "Winamp"
|
||||||
self._artist = "Unknown"
|
self._artist = "Unknown"
|
||||||
self._status = "Stopped"
|
self._status = "Stopped"
|
||||||
|
self._last_position_us = 0
|
||||||
|
self._total_length_us = 0
|
||||||
|
self._last_update_ts = time.time()
|
||||||
|
|
||||||
def _request(self, endpoint):
|
def _request(self, endpoint):
|
||||||
try:
|
try:
|
||||||
@@ -129,8 +134,17 @@ class WinampMPRIS:
|
|||||||
def CanControl(self): return True
|
def CanControl(self): return True
|
||||||
@property
|
@property
|
||||||
def CanSeek(self): return False
|
def CanSeek(self): return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def Position(self): return 0
|
def Position(self):
|
||||||
|
if self._status == "Playing":
|
||||||
|
elapsed_us = int((time.time() - 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
|
@property
|
||||||
def Volume(self): return 1.0
|
def Volume(self): return 1.0
|
||||||
|
|
||||||
@@ -139,21 +153,32 @@ class WinampMPRIS:
|
|||||||
# Metadata is a{sv}, so values MUST be wrapped in GLib.Variant
|
# Metadata is a{sv}, so values MUST be wrapped in GLib.Variant
|
||||||
return {
|
return {
|
||||||
'mpris:trackid': GLib.Variant('o', '/org/mpris/MediaPlayer2/winamp/track/0'),
|
'mpris:trackid': GLib.Variant('o', '/org/mpris/MediaPlayer2/winamp/track/0'),
|
||||||
'mpris:length': GLib.Variant('x', 0),
|
'mpris:length': GLib.Variant('x', self._total_length_us),
|
||||||
'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', ''),
|
'xesam:album': GLib.Variant('s', ''),
|
||||||
'mpris:artUrl': GLib.Variant('s', 'https://webamp.org/favicon.ico')
|
'mpris:artUrl': GLib.Variant('s', 'https://webamp.org/favicon.ico')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def parse_time_to_us(time_str):
|
||||||
|
"""Parses MM:SS or H:MM:SS to microseconds."""
|
||||||
|
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
|
||||||
|
return 0
|
||||||
|
|
||||||
def update_loop(player):
|
def update_loop(player):
|
||||||
last_known_title = ""
|
last_known_title = ""
|
||||||
last_known_status = ""
|
last_known_status = ""
|
||||||
|
offline_logged = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
r = requests.get(f"{BASE_URL}/main", auth=AUTH, timeout=2)
|
r = requests.get(f"{BASE_URL}/main", auth=AUTH, timeout=2)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
|
offline_logged = False
|
||||||
soup = BeautifulSoup(r.text, 'html.parser')
|
soup = BeautifulSoup(r.text, 'html.parser')
|
||||||
p_tags = soup.find_all('p')
|
p_tags = soup.find_all('p')
|
||||||
status_raw = p_tags[0].text if p_tags else ""
|
status_raw = p_tags[0].text if p_tags else ""
|
||||||
@@ -164,27 +189,37 @@ def update_loop(player):
|
|||||||
|
|
||||||
new_artist = "Unknown"
|
new_artist = "Unknown"
|
||||||
new_title = "Winamp"
|
new_title = "Winamp"
|
||||||
|
new_pos_us = 0
|
||||||
|
new_len_us = 0
|
||||||
|
|
||||||
match = re.search(r"track \d+ - (.*?) - (.*?) -", status_raw)
|
# Metadata match
|
||||||
if match:
|
match_meta = re.search(r"track \d+ - (.*?) - (.*?) -", status_raw)
|
||||||
new_artist = match.group(1).strip()
|
if match_meta:
|
||||||
new_title = match.group(2).strip()
|
new_artist = match_meta.group(1).strip()
|
||||||
|
new_title = match_meta.group(2).strip()
|
||||||
elif "track" in status_raw:
|
elif "track" in status_raw:
|
||||||
new_title = status_raw.split('-')[0].replace("Playing track ", "").strip()
|
new_title = status_raw.split('-')[0].replace("Playing track ", "").strip()
|
||||||
|
|
||||||
|
# Time match: (1:06 / 2:48)
|
||||||
|
match_time = re.search(r"\((\d+:?\d*:\d+) / (\d+:?\d*:\d+)\)", status_raw)
|
||||||
|
if match_time:
|
||||||
|
new_pos_us = parse_time_to_us(match_time.group(1))
|
||||||
|
new_len_us = parse_time_to_us(match_time.group(2))
|
||||||
|
|
||||||
|
# Update internal state
|
||||||
|
player._status = new_status
|
||||||
|
player._artist = new_artist
|
||||||
|
player._title = new_title
|
||||||
|
player._last_position_us = new_pos_us
|
||||||
|
player._total_length_us = new_len_us
|
||||||
|
player._last_update_ts = time.time()
|
||||||
|
|
||||||
if new_status != last_known_status or new_title != last_known_title:
|
if new_status != last_known_status or new_title != last_known_title:
|
||||||
player._status = new_status
|
|
||||||
player._artist = new_artist
|
|
||||||
player._title = new_title
|
|
||||||
last_known_status = new_status
|
last_known_status = new_status
|
||||||
last_known_title = new_title
|
last_known_title = new_title
|
||||||
|
|
||||||
print(f"UPDATE: [{player._status}] {player._artist} - {player._title}")
|
print(f"UPDATE: [{player._status}] {player._artist} - {player._title} ({match_time.group(1) if match_time else '0:00'})")
|
||||||
|
|
||||||
# pydbus PropertiesChanged helper will wrap these in Variants
|
|
||||||
# based on the introspection XML.
|
|
||||||
# Metadata is a{sv}, so its value (the dict) will be wrapped in Variant('a{sv}', ...)
|
|
||||||
# For that to work, the dict's values must already be Variants.
|
|
||||||
player.PropertiesChanged(
|
player.PropertiesChanged(
|
||||||
"org.mpris.MediaPlayer2.Player",
|
"org.mpris.MediaPlayer2.Player",
|
||||||
{
|
{
|
||||||
@@ -194,10 +229,30 @@ def update_loop(player):
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except (requests.exceptions.RequestException, Exception) as e:
|
||||||
print(f"Update error: {e}")
|
if not offline_logged:
|
||||||
import traceback
|
print(f"Winamp Web Interface offline or unreachable: {e}")
|
||||||
traceback.print_exc()
|
offline_logged = True
|
||||||
|
|
||||||
|
# Reset state when offline
|
||||||
|
player._status = "Stopped"
|
||||||
|
player._artist = "Unknown"
|
||||||
|
player._title = "Winamp (Offline)"
|
||||||
|
player._last_position_us = 0
|
||||||
|
player._total_length_us = 0
|
||||||
|
player._last_update_ts = time.time()
|
||||||
|
|
||||||
|
if last_known_status != "Stopped":
|
||||||
|
last_known_status = "Stopped"
|
||||||
|
player.PropertiesChanged(
|
||||||
|
"org.mpris.MediaPlayer2.Player",
|
||||||
|
{
|
||||||
|
"PlaybackStatus": player.PlaybackStatus,
|
||||||
|
"Metadata": player.Metadata
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user