Convert subprojects to git submodules
- haos-integration -> haos-hacs-emby-media-player.git - media-server -> media-player.git Each component is now an independent repository that can be developed and versioned separately. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "haos-integration"]
|
||||||
|
path = haos-integration
|
||||||
|
url = https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-emby-media-player.git
|
||||||
|
[submodule "media-server"]
|
||||||
|
path = media-server
|
||||||
|
url = https://git.dolgolyov-family.by/alexei.dolgolyov/media-player.git
|
||||||
1
haos-integration
Submodule
1
haos-integration
Submodule
Submodule haos-integration added at 783771489a
48
haos-integration/.gitignore
vendored
48
haos-integration/.gitignore
vendored
@@ -1,48 +0,0 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env/
|
|
||||||
.venv/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
.claude/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Config files with secrets
|
|
||||||
config.yaml
|
|
||||||
config.json
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
logs/
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# HAOS Integration - Development Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
HACS-ready Home Assistant custom integration for controlling remote PC media playback.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Copy `custom_components/remote_media_player/` to your Home Assistant config folder.
|
|
||||||
|
|
||||||
Or install via HACS as a custom repository.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
Requires Media Server running on the target PC.
|
|
||||||
|
|
||||||
Media Server Repository: `TODO: Add repository URL`
|
|
||||||
|
|
||||||
## Integration Location
|
|
||||||
|
|
||||||
Integration files location: `U:\custom_components\remote_media_player`
|
|
||||||
|
|
||||||
## Git Rules
|
|
||||||
|
|
||||||
Always ask for user approval before committing changes to git.
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Remote Media Player - Home Assistant Integration
|
|
||||||
|
|
||||||
[](https://github.com/hacs/integration)
|
|
||||||
[](https://github.com/YOUR_USERNAME/haos-remote-media-player/releases)
|
|
||||||
|
|
||||||
A Home Assistant custom integration that allows you to control a remote PC's media playback as a media player entity.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Full media player controls (play, pause, stop, next, previous)
|
|
||||||
- Volume control and mute
|
|
||||||
- Seek support with smooth timeline updates
|
|
||||||
- Displays current track info (title, artist, album, artwork)
|
|
||||||
- Real-time updates via WebSocket (with HTTP polling fallback)
|
|
||||||
- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.)
|
|
||||||
- Configurable via Home Assistant UI
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Home Assistant 2024.1.0 or newer
|
|
||||||
- A running **Media Server** on your PC
|
|
||||||
|
|
||||||
### Media Server
|
|
||||||
|
|
||||||
This integration requires the Media Server to be running on the PC you want to control.
|
|
||||||
|
|
||||||
**Media Server Repository:** `TODO: Add repository URL`
|
|
||||||
|
|
||||||
See the Media Server documentation for installation and setup instructions.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### HACS (Recommended)
|
|
||||||
|
|
||||||
1. Open HACS in Home Assistant
|
|
||||||
2. Click the three dots menu > **Custom repositories**
|
|
||||||
3. Add this repository URL: `https://github.com/YOUR_USERNAME/haos-remote-media-player`
|
|
||||||
4. Select category: **Integration**
|
|
||||||
5. Click **Add**
|
|
||||||
6. Search for "Remote Media Player" and click **Download**
|
|
||||||
7. Restart Home Assistant
|
|
||||||
|
|
||||||
### Manual Installation
|
|
||||||
|
|
||||||
1. Download the latest release from the [Releases](https://github.com/YOUR_USERNAME/haos-remote-media-player/releases) page
|
|
||||||
2. Extract and copy the `custom_components/remote_media_player` folder to your Home Assistant `config/custom_components/` directory
|
|
||||||
3. Restart Home Assistant
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
1. Go to **Settings** > **Devices & Services**
|
|
||||||
2. Click **+ Add Integration**
|
|
||||||
3. Search for "Remote Media Player"
|
|
||||||
4. Enter the connection details:
|
|
||||||
- **Host**: IP address or hostname of your PC running Media Server
|
|
||||||
- **Port**: Server port (default: 8765)
|
|
||||||
- **API Token**: The authentication token from your Media Server
|
|
||||||
- **Name**: Display name for this media player (optional)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Once configured, the integration creates:
|
|
||||||
|
|
||||||
### Media Player Entity
|
|
||||||
|
|
||||||
A full-featured media player entity with:
|
|
||||||
- Play/Pause/Stop controls
|
|
||||||
- Next/Previous track
|
|
||||||
- Volume control and mute
|
|
||||||
- Seek functionality
|
|
||||||
- Current track information
|
|
||||||
|
|
||||||
### Script Button Entities
|
|
||||||
|
|
||||||
Button entities for each script defined on your Media Server:
|
|
||||||
- Lock/unlock workstation
|
|
||||||
- Shutdown, restart, sleep, hibernate
|
|
||||||
- Custom scripts
|
|
||||||
|
|
||||||
## Example Lovelace Card
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
type: media-control
|
|
||||||
entity: media_player.remote_media_player
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
For detailed documentation, see [custom_components/remote_media_player/README.md](custom_components/remote_media_player/README.md).
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- [Report an Issue](https://github.com/YOUR_USERNAME/haos-remote-media-player/issues)
|
|
||||||
- [Media Server Repository](TODO)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
# Remote Media Player - Home Assistant Integration
|
|
||||||
|
|
||||||
A Home Assistant custom component that allows you to control a remote PC's media playback as a media player entity.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Full media player controls (play, pause, stop, next, previous)
|
|
||||||
- Volume control and mute
|
|
||||||
- Seek support
|
|
||||||
- Displays current track info (title, artist, album, artwork)
|
|
||||||
- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.)
|
|
||||||
- Configurable via Home Assistant UI
|
|
||||||
- Polling with adjustable interval
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Home Assistant 2024.1.0 or newer
|
|
||||||
- A running Media Server on your PC (see Media Server repository)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### HACS (Recommended)
|
|
||||||
|
|
||||||
1. Open HACS in Home Assistant
|
|
||||||
2. Click the three dots menu > Custom repositories
|
|
||||||
3. Add this repository URL and select "Integration"
|
|
||||||
4. Search for "Remote Media Player" and install
|
|
||||||
5. Restart Home Assistant
|
|
||||||
|
|
||||||
### Manual Installation
|
|
||||||
|
|
||||||
1. Copy the `remote_media_player` folder to your Home Assistant `config/custom_components/` directory:
|
|
||||||
```
|
|
||||||
config/
|
|
||||||
└── custom_components/
|
|
||||||
└── remote_media_player/
|
|
||||||
├── __init__.py
|
|
||||||
├── api_client.py
|
|
||||||
├── button.py
|
|
||||||
├── config_flow.py
|
|
||||||
├── const.py
|
|
||||||
├── manifest.json
|
|
||||||
├── media_player.py
|
|
||||||
├── services.yaml
|
|
||||||
├── strings.json
|
|
||||||
└── translations/
|
|
||||||
└── en.json
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Restart Home Assistant
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Via UI (Recommended)
|
|
||||||
|
|
||||||
1. Go to **Settings** > **Devices & Services**
|
|
||||||
2. Click **+ Add Integration**
|
|
||||||
3. Search for "Remote Media Player"
|
|
||||||
4. Enter the connection details:
|
|
||||||
- **Host**: IP address or hostname of your PC running Media Server
|
|
||||||
- **Port**: Server port (default: 8765)
|
|
||||||
- **API Token**: The authentication token from your server
|
|
||||||
- **Name**: Display name for this media player (optional)
|
|
||||||
- **Poll Interval**: How often to update status (default: 5 seconds)
|
|
||||||
|
|
||||||
### Finding Your API Token
|
|
||||||
|
|
||||||
On the PC running Media Server:
|
|
||||||
```bash
|
|
||||||
python -m media_server.main --show-token
|
|
||||||
```
|
|
||||||
|
|
||||||
Or check the config file:
|
|
||||||
- Windows: `%APPDATA%\media-server\config.yaml`
|
|
||||||
- Linux/macOS: `~/.config/media-server/config.yaml`
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Once configured, the integration creates a media player entity that you can:
|
|
||||||
|
|
||||||
### Control via UI
|
|
||||||
- Use the media player card in Lovelace
|
|
||||||
- Control from the entity's detail page
|
|
||||||
|
|
||||||
### Control via Services
|
|
||||||
```yaml
|
|
||||||
# Play
|
|
||||||
service: media_player.media_play
|
|
||||||
target:
|
|
||||||
entity_id: media_player.remote_media_player
|
|
||||||
|
|
||||||
# Pause
|
|
||||||
service: media_player.media_pause
|
|
||||||
target:
|
|
||||||
entity_id: media_player.remote_media_player
|
|
||||||
|
|
||||||
# Set volume (0.0 - 1.0)
|
|
||||||
service: media_player.volume_set
|
|
||||||
target:
|
|
||||||
entity_id: media_player.remote_media_player
|
|
||||||
data:
|
|
||||||
volume_level: 0.5
|
|
||||||
|
|
||||||
# Mute
|
|
||||||
service: media_player.volume_mute
|
|
||||||
target:
|
|
||||||
entity_id: media_player.remote_media_player
|
|
||||||
data:
|
|
||||||
is_volume_muted: true
|
|
||||||
|
|
||||||
# Next/Previous track
|
|
||||||
service: media_player.media_next_track
|
|
||||||
target:
|
|
||||||
entity_id: media_player.remote_media_player
|
|
||||||
|
|
||||||
# Seek to position (seconds)
|
|
||||||
service: media_player.media_seek
|
|
||||||
target:
|
|
||||||
entity_id: media_player.remote_media_player
|
|
||||||
data:
|
|
||||||
seek_position: 60
|
|
||||||
```
|
|
||||||
|
|
||||||
### Automations
|
|
||||||
|
|
||||||
Example: Pause PC media when leaving home
|
|
||||||
```yaml
|
|
||||||
automation:
|
|
||||||
- alias: "Pause PC media when leaving"
|
|
||||||
trigger:
|
|
||||||
- platform: state
|
|
||||||
entity_id: person.your_name
|
|
||||||
from: "home"
|
|
||||||
action:
|
|
||||||
- service: media_player.media_pause
|
|
||||||
target:
|
|
||||||
entity_id: media_player.remote_media_player
|
|
||||||
```
|
|
||||||
|
|
||||||
Example: Lower PC volume during quiet hours
|
|
||||||
```yaml
|
|
||||||
automation:
|
|
||||||
- alias: "Lower PC volume at night"
|
|
||||||
trigger:
|
|
||||||
- platform: time
|
|
||||||
at: "22:00:00"
|
|
||||||
action:
|
|
||||||
- service: media_player.volume_set
|
|
||||||
target:
|
|
||||||
entity_id: media_player.remote_media_player
|
|
||||||
data:
|
|
||||||
volume_level: 0.3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Script Buttons
|
|
||||||
|
|
||||||
The integration automatically creates **button entities** for each script defined on your Media Server. These buttons allow you to:
|
|
||||||
|
|
||||||
- Lock/unlock the workstation
|
|
||||||
- Shutdown, restart, or put the PC to sleep
|
|
||||||
- Hibernate the PC
|
|
||||||
- Execute custom commands
|
|
||||||
|
|
||||||
### Available Buttons
|
|
||||||
|
|
||||||
After setup, you'll see button entities like:
|
|
||||||
- `button.remote_media_player_lock_screen`
|
|
||||||
- `button.remote_media_player_shutdown`
|
|
||||||
- `button.remote_media_player_restart`
|
|
||||||
- `button.remote_media_player_sleep`
|
|
||||||
- `button.remote_media_player_hibernate`
|
|
||||||
|
|
||||||
### Adding Scripts
|
|
||||||
|
|
||||||
Scripts are configured on the Media Server in `config.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
scripts:
|
|
||||||
lock_screen:
|
|
||||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
|
||||||
label: "Lock Screen"
|
|
||||||
description: "Lock the workstation"
|
|
||||||
timeout: 5
|
|
||||||
shell: true
|
|
||||||
```
|
|
||||||
|
|
||||||
After adding scripts, restart the Media Server and reload the integration in Home Assistant.
|
|
||||||
|
|
||||||
### Using Script Buttons
|
|
||||||
|
|
||||||
#### Via UI
|
|
||||||
Add button entities to your dashboard using a button card or entities card.
|
|
||||||
|
|
||||||
#### Via Automation
|
|
||||||
```yaml
|
|
||||||
automation:
|
|
||||||
- alias: "Lock PC when leaving home"
|
|
||||||
trigger:
|
|
||||||
- platform: state
|
|
||||||
entity_id: person.your_name
|
|
||||||
from: "home"
|
|
||||||
action:
|
|
||||||
- service: button.press
|
|
||||||
target:
|
|
||||||
entity_id: button.remote_media_player_lock_screen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Execute Script Service
|
|
||||||
|
|
||||||
You can also execute scripts with arguments using the service:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
service: remote_media_player.execute_script
|
|
||||||
data:
|
|
||||||
script_name: "echo_test"
|
|
||||||
args:
|
|
||||||
- "arg1"
|
|
||||||
- "arg2"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lovelace Card Examples
|
|
||||||
|
|
||||||
### Basic Media Control Card
|
|
||||||
```yaml
|
|
||||||
type: media-control
|
|
||||||
entity: media_player.remote_media_player
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mini Media Player (requires custom card)
|
|
||||||
```yaml
|
|
||||||
type: custom:mini-media-player
|
|
||||||
entity: media_player.remote_media_player
|
|
||||||
artwork: cover
|
|
||||||
source: icon
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entities Card
|
|
||||||
```yaml
|
|
||||||
type: entities
|
|
||||||
entities:
|
|
||||||
- entity: media_player.remote_media_player
|
|
||||||
type: custom:slider-entity-row
|
|
||||||
full_row: true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Entity Attributes
|
|
||||||
|
|
||||||
The media player entity exposes these attributes:
|
|
||||||
|
|
||||||
| Attribute | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `media_title` | Current track title |
|
|
||||||
| `media_artist` | Current artist |
|
|
||||||
| `media_album_name` | Current album |
|
|
||||||
| `media_duration` | Track duration in seconds |
|
|
||||||
| `media_position` | Current position in seconds |
|
|
||||||
| `volume_level` | Volume (0.0 - 1.0) |
|
|
||||||
| `is_volume_muted` | Mute state |
|
|
||||||
| `source` | Media source/player name |
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
After initial setup, you can adjust options:
|
|
||||||
|
|
||||||
1. Go to **Settings** > **Devices & Services**
|
|
||||||
2. Find "Remote Media Player" and click **Configure**
|
|
||||||
3. Adjust the poll interval as needed
|
|
||||||
|
|
||||||
Lower poll intervals = more responsive but more network traffic.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Integration not found
|
|
||||||
- Restart Home Assistant after installing
|
|
||||||
- Check that all files are in the correct location
|
|
||||||
- Check Home Assistant logs for errors
|
|
||||||
|
|
||||||
### Cannot connect to server
|
|
||||||
- Verify the server is running: `curl http://YOUR_PC_IP:8765/api/health`
|
|
||||||
- Check firewall settings on the PC
|
|
||||||
- Ensure the IP address is correct
|
|
||||||
|
|
||||||
### Invalid token error
|
|
||||||
- Double-check the token matches exactly
|
|
||||||
- Regenerate token if needed: `python -m media_server.main --generate-config`
|
|
||||||
|
|
||||||
### Entity shows unavailable
|
|
||||||
- Check server is running
|
|
||||||
- Check network connectivity
|
|
||||||
- Review Home Assistant logs for connection errors
|
|
||||||
|
|
||||||
### Media controls don't work
|
|
||||||
- Ensure media is playing on the PC
|
|
||||||
- Check server logs for errors
|
|
||||||
- Verify the media player supports the requested action
|
|
||||||
|
|
||||||
## Multiple PCs
|
|
||||||
|
|
||||||
You can add multiple Media Server instances:
|
|
||||||
|
|
||||||
1. Run Media Server on each PC (use different tokens)
|
|
||||||
2. Add the integration multiple times in Home Assistant
|
|
||||||
3. Give each a unique name
|
|
||||||
|
|
||||||
## Supported Features
|
|
||||||
|
|
||||||
| Feature | Supported |
|
|
||||||
|---------|-----------|
|
|
||||||
| Play | Yes |
|
|
||||||
| Pause | Yes |
|
|
||||||
| Stop | Yes |
|
|
||||||
| Next Track | Yes |
|
|
||||||
| Previous Track | Yes |
|
|
||||||
| Volume Set | Yes |
|
|
||||||
| Volume Mute | Yes |
|
|
||||||
| Seek | Yes |
|
|
||||||
| Script Buttons | Yes |
|
|
||||||
| Browse Media | No |
|
|
||||||
| Play Media | No |
|
|
||||||
| Shuffle/Repeat | No |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
"""The Remote Media Player integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
|
|
||||||
from .api_client import MediaServerClient, MediaServerError
|
|
||||||
from .const import (
|
|
||||||
ATTR_SCRIPT_ARGS,
|
|
||||||
ATTR_SCRIPT_NAME,
|
|
||||||
CONF_HOST,
|
|
||||||
CONF_PORT,
|
|
||||||
CONF_TOKEN,
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_EXECUTE_SCRIPT,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON]
|
|
||||||
|
|
||||||
# Service schema for execute_script
|
|
||||||
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_SCRIPT_NAME): cv.string,
|
|
||||||
vol.Optional(ATTR_SCRIPT_ARGS, default=[]): vol.All(
|
|
||||||
cv.ensure_list, [cv.string]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Set up Remote Media Player from a config entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hass: Home Assistant instance
|
|
||||||
entry: Config entry
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if setup was successful
|
|
||||||
"""
|
|
||||||
_LOGGER.debug("Setting up Remote Media Player: %s", entry.entry_id)
|
|
||||||
|
|
||||||
# Create API client
|
|
||||||
client = MediaServerClient(
|
|
||||||
host=entry.data[CONF_HOST],
|
|
||||||
port=entry.data[CONF_PORT],
|
|
||||||
token=entry.data[CONF_TOKEN],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify connection
|
|
||||||
if not await client.check_connection():
|
|
||||||
_LOGGER.error("Failed to connect to Media Server")
|
|
||||||
await client.close()
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Store client in hass.data
|
|
||||||
hass.data.setdefault(DOMAIN, {})
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = {
|
|
||||||
"client": client,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Register services if not already registered
|
|
||||||
if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT):
|
|
||||||
async def async_execute_script(call: ServiceCall) -> dict[str, Any]:
|
|
||||||
"""Execute a script on the media server."""
|
|
||||||
script_name = call.data[ATTR_SCRIPT_NAME]
|
|
||||||
script_args = call.data.get(ATTR_SCRIPT_ARGS, [])
|
|
||||||
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Executing script '%s' with args: %s", script_name, script_args
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get all clients and execute on all of them
|
|
||||||
results = {}
|
|
||||||
for entry_id, data in hass.data[DOMAIN].items():
|
|
||||||
client: MediaServerClient = data["client"]
|
|
||||||
try:
|
|
||||||
result = await client.execute_script(script_name, script_args)
|
|
||||||
results[entry_id] = result
|
|
||||||
_LOGGER.info(
|
|
||||||
"Script '%s' executed on %s: success=%s",
|
|
||||||
script_name,
|
|
||||||
entry_id,
|
|
||||||
result.get("success", False),
|
|
||||||
)
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error(
|
|
||||||
"Failed to execute script '%s' on %s: %s",
|
|
||||||
script_name,
|
|
||||||
entry_id,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
results[entry_id] = {"success": False, "error": str(err)}
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_EXECUTE_SCRIPT,
|
|
||||||
async_execute_script,
|
|
||||||
schema=SERVICE_EXECUTE_SCRIPT_SCHEMA,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Forward setup to platforms
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
|
|
||||||
# Register update listener for options
|
|
||||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Unload a config entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hass: Home Assistant instance
|
|
||||||
entry: Config entry
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if unload was successful
|
|
||||||
"""
|
|
||||||
_LOGGER.debug("Unloading Remote Media Player: %s", entry.entry_id)
|
|
||||||
|
|
||||||
# Unload platforms
|
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
||||||
|
|
||||||
if unload_ok:
|
|
||||||
# Close client and remove data
|
|
||||||
data = hass.data[DOMAIN].pop(entry.entry_id)
|
|
||||||
|
|
||||||
# Shutdown coordinator (WebSocket cleanup)
|
|
||||||
if "coordinator" in data:
|
|
||||||
await data["coordinator"].async_shutdown()
|
|
||||||
|
|
||||||
# Close HTTP client
|
|
||||||
await data["client"].close()
|
|
||||||
|
|
||||||
# Remove services if this was the last entry
|
|
||||||
if not hass.data[DOMAIN]:
|
|
||||||
hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT)
|
|
||||||
|
|
||||||
return unload_ok
|
|
||||||
|
|
||||||
|
|
||||||
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
||||||
"""Handle options update.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hass: Home Assistant instance
|
|
||||||
entry: Config entry
|
|
||||||
"""
|
|
||||||
_LOGGER.debug("Options updated for: %s", entry.entry_id)
|
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
"""API client for communicating with the Media Server."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from aiohttp import ClientError, ClientResponseError
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
API_HEALTH,
|
|
||||||
API_STATUS,
|
|
||||||
API_PLAY,
|
|
||||||
API_PAUSE,
|
|
||||||
API_STOP,
|
|
||||||
API_NEXT,
|
|
||||||
API_PREVIOUS,
|
|
||||||
API_VOLUME,
|
|
||||||
API_MUTE,
|
|
||||||
API_SEEK,
|
|
||||||
API_SCRIPTS_LIST,
|
|
||||||
API_SCRIPTS_EXECUTE,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaServerError(Exception):
|
|
||||||
"""Base exception for Media Server errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class MediaServerConnectionError(MediaServerError):
|
|
||||||
"""Exception for connection errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class MediaServerAuthError(MediaServerError):
|
|
||||||
"""Exception for authentication errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class MediaServerClient:
|
|
||||||
"""Client for the Media Server REST API."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
token: str,
|
|
||||||
session: aiohttp.ClientSession | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the client.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
host: Server hostname or IP address
|
|
||||||
port: Server port
|
|
||||||
token: API authentication token
|
|
||||||
session: Optional aiohttp session (will create one if not provided)
|
|
||||||
"""
|
|
||||||
self._host = host
|
|
||||||
self._port = int(port) # Ensure port is an integer
|
|
||||||
self._token = token
|
|
||||||
self._session = session
|
|
||||||
self._own_session = session is None
|
|
||||||
self._base_url = f"http://{host}:{self._port}"
|
|
||||||
|
|
||||||
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
||||||
"""Ensure we have an aiohttp session."""
|
|
||||||
if self._session is None or self._session.closed:
|
|
||||||
self._session = aiohttp.ClientSession()
|
|
||||||
self._own_session = True
|
|
||||||
return self._session
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
"""Close the client session."""
|
|
||||||
if self._own_session and self._session and not self._session.closed:
|
|
||||||
await self._session.close()
|
|
||||||
|
|
||||||
def _get_headers(self) -> dict[str, str]:
|
|
||||||
"""Get headers for API requests."""
|
|
||||||
return {
|
|
||||||
"Authorization": f"Bearer {self._token}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
endpoint: str,
|
|
||||||
json_data: dict | None = None,
|
|
||||||
auth_required: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Make an API request.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
method: HTTP method (GET, POST, etc.)
|
|
||||||
endpoint: API endpoint path
|
|
||||||
json_data: Optional JSON body data
|
|
||||||
auth_required: Whether to include authentication header
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data as dictionary
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
MediaServerConnectionError: On connection errors
|
|
||||||
MediaServerAuthError: On authentication errors
|
|
||||||
MediaServerError: On other errors
|
|
||||||
"""
|
|
||||||
session = await self._ensure_session()
|
|
||||||
url = f"{self._base_url}{endpoint}"
|
|
||||||
headers = self._get_headers() if auth_required else {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
timeout = aiohttp.ClientTimeout(total=10)
|
|
||||||
async with session.request(
|
|
||||||
method, url, headers=headers, json=json_data, timeout=timeout
|
|
||||||
) as response:
|
|
||||||
if response.status == 401:
|
|
||||||
raise MediaServerAuthError("Invalid API token")
|
|
||||||
if response.status == 403:
|
|
||||||
raise MediaServerAuthError("Access forbidden")
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
return await response.json()
|
|
||||||
|
|
||||||
except aiohttp.ClientConnectorError as err:
|
|
||||||
raise MediaServerConnectionError(
|
|
||||||
f"Cannot connect to server at {self._base_url}: {err}"
|
|
||||||
) from err
|
|
||||||
except ClientResponseError as err:
|
|
||||||
raise MediaServerError(f"API error: {err.status} {err.message}") from err
|
|
||||||
except ClientError as err:
|
|
||||||
raise MediaServerConnectionError(f"Connection error: {err}") from err
|
|
||||||
|
|
||||||
async def check_connection(self) -> bool:
|
|
||||||
"""Check if the server is reachable and token is valid.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if connection is successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# First check health (no auth)
|
|
||||||
await self._request("GET", API_HEALTH, auth_required=False)
|
|
||||||
# Then check auth by getting status
|
|
||||||
await self._request("GET", API_STATUS)
|
|
||||||
return True
|
|
||||||
except MediaServerError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_health(self) -> dict[str, Any]:
|
|
||||||
"""Get server health status (no authentication required).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Health status data
|
|
||||||
"""
|
|
||||||
return await self._request("GET", API_HEALTH, auth_required=False)
|
|
||||||
|
|
||||||
async def get_status(self) -> dict[str, Any]:
|
|
||||||
"""Get current media playback status.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Media status data including state, title, artist, volume, etc.
|
|
||||||
"""
|
|
||||||
data = await self._request("GET", API_STATUS)
|
|
||||||
|
|
||||||
# Convert relative album_art_url to absolute URL with token and cache-buster
|
|
||||||
if data.get("album_art_url") and data["album_art_url"].startswith("/"):
|
|
||||||
# Add track info hash to force HA to re-fetch when track changes
|
|
||||||
import hashlib
|
|
||||||
track_id = f"{data.get('title', '')}-{data.get('artist', '')}"
|
|
||||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
|
||||||
data["album_art_url"] = f"{self._base_url}{data['album_art_url']}?token={self._token}&t={track_hash}"
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def play(self) -> dict[str, Any]:
|
|
||||||
"""Resume or start playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data
|
|
||||||
"""
|
|
||||||
return await self._request("POST", API_PLAY)
|
|
||||||
|
|
||||||
async def pause(self) -> dict[str, Any]:
|
|
||||||
"""Pause playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data
|
|
||||||
"""
|
|
||||||
return await self._request("POST", API_PAUSE)
|
|
||||||
|
|
||||||
async def stop(self) -> dict[str, Any]:
|
|
||||||
"""Stop playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data
|
|
||||||
"""
|
|
||||||
return await self._request("POST", API_STOP)
|
|
||||||
|
|
||||||
async def next_track(self) -> dict[str, Any]:
|
|
||||||
"""Skip to next track.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data
|
|
||||||
"""
|
|
||||||
return await self._request("POST", API_NEXT)
|
|
||||||
|
|
||||||
async def previous_track(self) -> dict[str, Any]:
|
|
||||||
"""Go to previous track.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data
|
|
||||||
"""
|
|
||||||
return await self._request("POST", API_PREVIOUS)
|
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> dict[str, Any]:
|
|
||||||
"""Set the volume level.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
volume: Volume level (0-100)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data
|
|
||||||
"""
|
|
||||||
return await self._request("POST", API_VOLUME, {"volume": volume})
|
|
||||||
|
|
||||||
async def toggle_mute(self) -> dict[str, Any]:
|
|
||||||
"""Toggle mute state.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data with new mute state
|
|
||||||
"""
|
|
||||||
return await self._request("POST", API_MUTE)
|
|
||||||
|
|
||||||
async def seek(self, position: float) -> dict[str, Any]:
|
|
||||||
"""Seek to a position in the current track.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
position: Position in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response data
|
|
||||||
"""
|
|
||||||
return await self._request("POST", API_SEEK, {"position": position})
|
|
||||||
|
|
||||||
async def list_scripts(self) -> list[dict[str, Any]]:
|
|
||||||
"""List available scripts on the server.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of scripts with name, description, and timeout
|
|
||||||
"""
|
|
||||||
return await self._request("GET", API_SCRIPTS_LIST)
|
|
||||||
|
|
||||||
async def execute_script(
|
|
||||||
self, script_name: str, args: list[str] | None = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Execute a script on the server.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
script_name: Name of the script to execute
|
|
||||||
args: Optional list of arguments to pass to the script
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Execution result with success, exit_code, stdout, stderr
|
|
||||||
"""
|
|
||||||
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
|
|
||||||
json_data = {"args": args or []}
|
|
||||||
return await self._request("POST", endpoint, json_data)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaServerWebSocket:
|
|
||||||
"""WebSocket client for real-time media status updates."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
token: str,
|
|
||||||
on_status_update: Callable[[dict[str, Any]], None],
|
|
||||||
on_disconnect: Callable[[], None] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the WebSocket client.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
host: Server hostname or IP
|
|
||||||
port: Server port
|
|
||||||
token: API authentication token
|
|
||||||
on_status_update: Callback when status update received
|
|
||||||
on_disconnect: Callback when connection lost
|
|
||||||
"""
|
|
||||||
self._host = host
|
|
||||||
self._port = int(port)
|
|
||||||
self._token = token
|
|
||||||
self._on_status_update = on_status_update
|
|
||||||
self._on_disconnect = on_disconnect
|
|
||||||
self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
|
|
||||||
self._session: aiohttp.ClientSession | None = None
|
|
||||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
||||||
self._receive_task: asyncio.Task | None = None
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
|
||||||
"""Establish WebSocket connection.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if connection successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if self._session is None or self._session.closed:
|
|
||||||
self._session = aiohttp.ClientSession()
|
|
||||||
|
|
||||||
self._ws = await self._session.ws_connect(
|
|
||||||
self._ws_url,
|
|
||||||
heartbeat=30,
|
|
||||||
timeout=aiohttp.ClientTimeout(total=10),
|
|
||||||
)
|
|
||||||
self._running = True
|
|
||||||
|
|
||||||
# Start receive loop
|
|
||||||
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
||||||
|
|
||||||
_LOGGER.info("WebSocket connected to %s:%s", self._host, self._port)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.warning("WebSocket connection failed: %s", err)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""Close WebSocket connection."""
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
if self._receive_task:
|
|
||||||
self._receive_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._receive_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
self._receive_task = None
|
|
||||||
|
|
||||||
if self._ws and not self._ws.closed:
|
|
||||||
await self._ws.close()
|
|
||||||
self._ws = None
|
|
||||||
|
|
||||||
if self._session and not self._session.closed:
|
|
||||||
await self._session.close()
|
|
||||||
self._session = None
|
|
||||||
|
|
||||||
_LOGGER.debug("WebSocket disconnected")
|
|
||||||
|
|
||||||
async def _receive_loop(self) -> None:
|
|
||||||
"""Background loop to receive WebSocket messages."""
|
|
||||||
while self._running and self._ws and not self._ws.closed:
|
|
||||||
try:
|
|
||||||
msg = await self._ws.receive(timeout=60)
|
|
||||||
|
|
||||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
||||||
data = msg.json()
|
|
||||||
msg_type = data.get("type")
|
|
||||||
|
|
||||||
if msg_type in ("status", "status_update"):
|
|
||||||
status_data = data.get("data", {})
|
|
||||||
# Convert album art URL to absolute
|
|
||||||
if (
|
|
||||||
status_data.get("album_art_url")
|
|
||||||
and status_data["album_art_url"].startswith("/")
|
|
||||||
):
|
|
||||||
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
|
|
||||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
|
||||||
status_data["album_art_url"] = (
|
|
||||||
f"http://{self._host}:{self._port}"
|
|
||||||
f"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
|
|
||||||
)
|
|
||||||
self._on_status_update(status_data)
|
|
||||||
elif msg_type == "pong":
|
|
||||||
_LOGGER.debug("Received pong")
|
|
||||||
|
|
||||||
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
|
||||||
_LOGGER.warning("WebSocket closed by server")
|
|
||||||
break
|
|
||||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
||||||
_LOGGER.error("WebSocket error: %s", self._ws.exception())
|
|
||||||
break
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# Send ping to keep connection alive
|
|
||||||
if self._ws and not self._ws.closed:
|
|
||||||
try:
|
|
||||||
await self._ws.send_json({"type": "ping"})
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.error("WebSocket receive error: %s", err)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Connection lost, notify callback
|
|
||||||
if self._on_disconnect:
|
|
||||||
self._on_disconnect()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
"""Return True if WebSocket is connected."""
|
|
||||||
return self._ws is not None and not self._ws.closed
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"""Button platform for Remote Media Player integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homeassistant.components.button import ButtonEntity
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
||||||
|
|
||||||
from .api_client import MediaServerClient, MediaServerError
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: ConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up script buttons from a config entry."""
|
|
||||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
scripts = await client.list_scripts()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to fetch scripts list: %s", err)
|
|
||||||
return
|
|
||||||
|
|
||||||
entities = [
|
|
||||||
ScriptButtonEntity(
|
|
||||||
client=client,
|
|
||||||
entry=entry,
|
|
||||||
script_name=script["name"],
|
|
||||||
script_label=script["label"],
|
|
||||||
script_description=script.get("description", ""),
|
|
||||||
script_icon=script.get("icon"),
|
|
||||||
)
|
|
||||||
for script in scripts
|
|
||||||
]
|
|
||||||
|
|
||||||
if entities:
|
|
||||||
async_add_entities(entities)
|
|
||||||
_LOGGER.info("Added %d script button entities", len(entities))
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptButtonEntity(ButtonEntity):
|
|
||||||
"""Button entity for executing a script on the media server."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
client: MediaServerClient,
|
|
||||||
entry: ConfigEntry,
|
|
||||||
script_name: str,
|
|
||||||
script_label: str,
|
|
||||||
script_description: str,
|
|
||||||
script_icon: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the script button."""
|
|
||||||
self._client = client
|
|
||||||
self._entry = entry
|
|
||||||
self._script_name = script_name
|
|
||||||
self._script_label = script_label
|
|
||||||
self._script_description = script_description
|
|
||||||
|
|
||||||
# Entity attributes
|
|
||||||
self._attr_unique_id = f"{entry.entry_id}_script_{script_name}"
|
|
||||||
self._attr_name = script_label
|
|
||||||
# Use custom icon if provided, otherwise auto-resolve from script name
|
|
||||||
self._attr_icon = script_icon or self._get_icon_for_script(script_name)
|
|
||||||
|
|
||||||
def _get_icon_for_script(self, script_name: str) -> str:
|
|
||||||
"""Get an appropriate icon based on script name."""
|
|
||||||
icon_map = {
|
|
||||||
"lock": "mdi:lock",
|
|
||||||
"unlock": "mdi:lock-open",
|
|
||||||
"shutdown": "mdi:power",
|
|
||||||
"restart": "mdi:restart",
|
|
||||||
"sleep": "mdi:sleep",
|
|
||||||
"hibernate": "mdi:power-sleep",
|
|
||||||
"cancel": "mdi:cancel",
|
|
||||||
}
|
|
||||||
|
|
||||||
script_lower = script_name.lower()
|
|
||||||
for keyword, icon in icon_map.items():
|
|
||||||
if keyword in script_lower:
|
|
||||||
return icon
|
|
||||||
|
|
||||||
return "mdi:script-text"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def device_info(self) -> DeviceInfo:
|
|
||||||
"""Return device info."""
|
|
||||||
return DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
|
||||||
name=self._entry.title,
|
|
||||||
manufacturer="Remote Media Player",
|
|
||||||
model="Media Server",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra_state_attributes(self) -> dict[str, Any]:
|
|
||||||
"""Return extra state attributes."""
|
|
||||||
return {
|
|
||||||
"script_name": self._script_name,
|
|
||||||
"description": self._script_description,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def async_press(self) -> None:
|
|
||||||
"""Handle button press - execute the script."""
|
|
||||||
_LOGGER.info("Executing script: %s", self._script_name)
|
|
||||||
try:
|
|
||||||
result = await self._client.execute_script(self._script_name)
|
|
||||||
if result.get("success"):
|
|
||||||
_LOGGER.info(
|
|
||||||
"Script '%s' executed successfully (exit_code=%s)",
|
|
||||||
self._script_name,
|
|
||||||
result.get("exit_code"),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Script '%s' failed: %s",
|
|
||||||
self._script_name,
|
|
||||||
result.get("stderr") or result.get("error"),
|
|
||||||
)
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to execute script '%s': %s", self._script_name, err)
|
|
||||||
raise
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
"""Config flow for Remote Media Player integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant import config_entries
|
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
|
||||||
from homeassistant.helpers import selector
|
|
||||||
|
|
||||||
from .api_client import (
|
|
||||||
MediaServerClient,
|
|
||||||
MediaServerConnectionError,
|
|
||||||
MediaServerAuthError,
|
|
||||||
)
|
|
||||||
from .const import (
|
|
||||||
DOMAIN,
|
|
||||||
CONF_TOKEN,
|
|
||||||
CONF_POLL_INTERVAL,
|
|
||||||
DEFAULT_PORT,
|
|
||||||
DEFAULT_POLL_INTERVAL,
|
|
||||||
DEFAULT_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Validate the user input allows us to connect.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hass: Home Assistant instance
|
|
||||||
data: User input data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Validated data with title
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
CannotConnect: If connection fails
|
|
||||||
InvalidAuth: If authentication fails
|
|
||||||
"""
|
|
||||||
client = MediaServerClient(
|
|
||||||
host=data[CONF_HOST],
|
|
||||||
port=data[CONF_PORT],
|
|
||||||
token=data[CONF_TOKEN],
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
health = await client.get_health()
|
|
||||||
# Try authenticated endpoint
|
|
||||||
await client.get_status()
|
|
||||||
except MediaServerConnectionError as err:
|
|
||||||
await client.close()
|
|
||||||
raise CannotConnect(str(err)) from err
|
|
||||||
except MediaServerAuthError as err:
|
|
||||||
await client.close()
|
|
||||||
raise InvalidAuth(str(err)) from err
|
|
||||||
finally:
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
# Return info to store in the config entry
|
|
||||||
return {
|
|
||||||
"title": data.get(CONF_NAME, DEFAULT_NAME),
|
|
||||||
"platform": health.get("platform", "Unknown"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Remote Media Player."""
|
|
||||||
|
|
||||||
VERSION = 1
|
|
||||||
|
|
||||||
async def async_step_user(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> FlowResult:
|
|
||||||
"""Handle the initial step.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_input: User provided configuration
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Flow result
|
|
||||||
"""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
try:
|
|
||||||
info = await validate_input(self.hass, user_input)
|
|
||||||
except CannotConnect:
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
except InvalidAuth:
|
|
||||||
errors["base"] = "invalid_auth"
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
_LOGGER.exception("Unexpected exception")
|
|
||||||
errors["base"] = "unknown"
|
|
||||||
else:
|
|
||||||
# Check if already configured
|
|
||||||
await self.async_set_unique_id(
|
|
||||||
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
|
|
||||||
)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=info["title"],
|
|
||||||
data=user_input,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Show configuration form
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_HOST): selector.TextSelector(
|
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
||||||
),
|
|
||||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=1,
|
|
||||||
max=65535,
|
|
||||||
mode=selector.NumberSelectorMode.BOX,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Required(CONF_TOKEN): selector.TextSelector(
|
|
||||||
selector.TextSelectorConfig(
|
|
||||||
type=selector.TextSelectorType.PASSWORD
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
|
|
||||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
||||||
),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=1,
|
|
||||||
max=60,
|
|
||||||
step=1,
|
|
||||||
unit_of_measurement="seconds",
|
|
||||||
mode=selector.NumberSelectorMode.SLIDER,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@callback
|
|
||||||
def async_get_options_flow(
|
|
||||||
config_entry: config_entries.ConfigEntry,
|
|
||||||
) -> config_entries.OptionsFlow:
|
|
||||||
"""Create the options flow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_entry: Config entry
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Options flow handler
|
|
||||||
"""
|
|
||||||
return OptionsFlowHandler(config_entry)
|
|
||||||
|
|
||||||
|
|
||||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
|
||||||
"""Handle options flow for Remote Media Player."""
|
|
||||||
|
|
||||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
|
||||||
"""Initialize options flow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_entry: Config entry
|
|
||||||
"""
|
|
||||||
self._config_entry = config_entry
|
|
||||||
|
|
||||||
async def async_step_init(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> FlowResult:
|
|
||||||
"""Manage the options.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_input: User provided options
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Flow result
|
|
||||||
"""
|
|
||||||
if user_input is not None:
|
|
||||||
return self.async_create_entry(title="", data=user_input)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="init",
|
|
||||||
data_schema=vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(
|
|
||||||
CONF_POLL_INTERVAL,
|
|
||||||
default=self._config_entry.options.get(
|
|
||||||
CONF_POLL_INTERVAL,
|
|
||||||
self._config_entry.data.get(
|
|
||||||
CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL
|
|
||||||
),
|
|
||||||
),
|
|
||||||
): selector.NumberSelector(
|
|
||||||
selector.NumberSelectorConfig(
|
|
||||||
min=1,
|
|
||||||
max=60,
|
|
||||||
step=1,
|
|
||||||
unit_of_measurement="seconds",
|
|
||||||
mode=selector.NumberSelectorMode.SLIDER,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CannotConnect(Exception):
|
|
||||||
"""Error to indicate we cannot connect."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidAuth(Exception):
|
|
||||||
"""Error to indicate there is invalid auth."""
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Constants for the Remote Media Player integration."""
|
|
||||||
|
|
||||||
DOMAIN = "remote_media_player"
|
|
||||||
|
|
||||||
# Configuration keys
|
|
||||||
CONF_HOST = "host"
|
|
||||||
CONF_PORT = "port"
|
|
||||||
CONF_TOKEN = "token"
|
|
||||||
CONF_POLL_INTERVAL = "poll_interval"
|
|
||||||
CONF_NAME = "name"
|
|
||||||
CONF_USE_WEBSOCKET = "use_websocket"
|
|
||||||
|
|
||||||
# Default values
|
|
||||||
DEFAULT_PORT = 8765
|
|
||||||
DEFAULT_POLL_INTERVAL = 5
|
|
||||||
DEFAULT_NAME = "Remote Media Player"
|
|
||||||
DEFAULT_USE_WEBSOCKET = True
|
|
||||||
DEFAULT_RECONNECT_INTERVAL = 30
|
|
||||||
|
|
||||||
# API endpoints
|
|
||||||
API_HEALTH = "/api/health"
|
|
||||||
API_STATUS = "/api/media/status"
|
|
||||||
API_PLAY = "/api/media/play"
|
|
||||||
API_PAUSE = "/api/media/pause"
|
|
||||||
API_STOP = "/api/media/stop"
|
|
||||||
API_NEXT = "/api/media/next"
|
|
||||||
API_PREVIOUS = "/api/media/previous"
|
|
||||||
API_VOLUME = "/api/media/volume"
|
|
||||||
API_MUTE = "/api/media/mute"
|
|
||||||
API_SEEK = "/api/media/seek"
|
|
||||||
API_SCRIPTS_LIST = "/api/scripts/list"
|
|
||||||
API_SCRIPTS_EXECUTE = "/api/scripts/execute"
|
|
||||||
API_WEBSOCKET = "/api/media/ws"
|
|
||||||
|
|
||||||
# Service names
|
|
||||||
SERVICE_EXECUTE_SCRIPT = "execute_script"
|
|
||||||
|
|
||||||
# Service attributes
|
|
||||||
ATTR_SCRIPT_NAME = "script_name"
|
|
||||||
ATTR_SCRIPT_ARGS = "args"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "remote_media_player",
|
|
||||||
"name": "Remote Media Player",
|
|
||||||
"codeowners": [],
|
|
||||||
"config_flow": true,
|
|
||||||
"dependencies": [],
|
|
||||||
"documentation": "https://github.com/YOUR_USERNAME/haos-remote-media-player",
|
|
||||||
"integration_type": "device",
|
|
||||||
"iot_class": "local_push",
|
|
||||||
"requirements": ["aiohttp>=3.8.0"],
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
"""Media player platform for Remote Media Player integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
|
||||||
MediaPlayerEntity,
|
|
||||||
MediaPlayerEntityFeature,
|
|
||||||
MediaPlayerState,
|
|
||||||
MediaType,
|
|
||||||
)
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import CONF_NAME
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
||||||
from homeassistant.helpers.update_coordinator import (
|
|
||||||
CoordinatorEntity,
|
|
||||||
DataUpdateCoordinator,
|
|
||||||
UpdateFailed,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .api_client import MediaServerClient, MediaServerError, MediaServerWebSocket
|
|
||||||
from .const import (
|
|
||||||
DOMAIN,
|
|
||||||
CONF_HOST,
|
|
||||||
CONF_PORT,
|
|
||||||
CONF_TOKEN,
|
|
||||||
CONF_POLL_INTERVAL,
|
|
||||||
CONF_USE_WEBSOCKET,
|
|
||||||
DEFAULT_POLL_INTERVAL,
|
|
||||||
DEFAULT_NAME,
|
|
||||||
DEFAULT_USE_WEBSOCKET,
|
|
||||||
DEFAULT_RECONNECT_INTERVAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: ConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up the media player platform.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hass: Home Assistant instance
|
|
||||||
entry: Config entry
|
|
||||||
async_add_entities: Callback to add entities
|
|
||||||
"""
|
|
||||||
_LOGGER.debug("Setting up media player platform for %s", entry.entry_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
|
||||||
except KeyError:
|
|
||||||
_LOGGER.error("Client not found in hass.data for entry %s", entry.entry_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get poll interval from options or data
|
|
||||||
poll_interval = entry.options.get(
|
|
||||||
CONF_POLL_INTERVAL,
|
|
||||||
entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get WebSocket setting from options or data
|
|
||||||
use_websocket = entry.options.get(
|
|
||||||
CONF_USE_WEBSOCKET,
|
|
||||||
entry.data.get(CONF_USE_WEBSOCKET, DEFAULT_USE_WEBSOCKET),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create update coordinator with WebSocket support
|
|
||||||
coordinator = MediaPlayerCoordinator(
|
|
||||||
hass,
|
|
||||||
client,
|
|
||||||
poll_interval,
|
|
||||||
host=entry.data[CONF_HOST],
|
|
||||||
port=entry.data[CONF_PORT],
|
|
||||||
token=entry.data[CONF_TOKEN],
|
|
||||||
use_websocket=use_websocket,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set up WebSocket connection if enabled
|
|
||||||
await coordinator.async_setup()
|
|
||||||
|
|
||||||
# Fetch initial data - don't fail setup if this fails
|
|
||||||
try:
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.warning("Initial data fetch failed, will retry: %s", err)
|
|
||||||
# Continue anyway - the coordinator will retry
|
|
||||||
|
|
||||||
# Store coordinator for cleanup
|
|
||||||
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
|
|
||||||
|
|
||||||
# Create and add entity
|
|
||||||
entity = RemoteMediaPlayerEntity(
|
|
||||||
coordinator,
|
|
||||||
entry,
|
|
||||||
)
|
|
||||||
_LOGGER.info("Adding media player entity: %s", entity.unique_id)
|
|
||||||
async_add_entities([entity])
|
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|
||||||
"""Coordinator for fetching media player data with WebSocket support."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
client: MediaServerClient,
|
|
||||||
poll_interval: int,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
token: str,
|
|
||||||
use_websocket: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the coordinator.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
hass: Home Assistant instance
|
|
||||||
client: Media Server API client
|
|
||||||
poll_interval: Update interval in seconds
|
|
||||||
host: Server hostname
|
|
||||||
port: Server port
|
|
||||||
token: API token
|
|
||||||
use_websocket: Whether to use WebSocket for updates
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
name="Remote Media Player",
|
|
||||||
update_interval=timedelta(seconds=poll_interval),
|
|
||||||
)
|
|
||||||
self.client = client
|
|
||||||
self._host = host
|
|
||||||
self._port = port
|
|
||||||
self._token = token
|
|
||||||
self._use_websocket = use_websocket
|
|
||||||
self._ws_client: MediaServerWebSocket | None = None
|
|
||||||
self._ws_connected = False
|
|
||||||
self._reconnect_task: asyncio.Task | None = None
|
|
||||||
self._poll_interval = poll_interval
|
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
|
||||||
"""Set up the coordinator with WebSocket if enabled."""
|
|
||||||
if self._use_websocket:
|
|
||||||
await self._connect_websocket()
|
|
||||||
|
|
||||||
async def _connect_websocket(self) -> None:
|
|
||||||
"""Establish WebSocket connection."""
|
|
||||||
if self._ws_client:
|
|
||||||
await self._ws_client.disconnect()
|
|
||||||
|
|
||||||
self._ws_client = MediaServerWebSocket(
|
|
||||||
host=self._host,
|
|
||||||
port=self._port,
|
|
||||||
token=self._token,
|
|
||||||
on_status_update=self._handle_ws_status_update,
|
|
||||||
on_disconnect=self._handle_ws_disconnect,
|
|
||||||
)
|
|
||||||
|
|
||||||
if await self._ws_client.connect():
|
|
||||||
self._ws_connected = True
|
|
||||||
# Disable polling - WebSocket handles all updates including position
|
|
||||||
self.update_interval = None
|
|
||||||
_LOGGER.info("WebSocket connected, polling disabled")
|
|
||||||
else:
|
|
||||||
self._ws_connected = False
|
|
||||||
# Keep polling as fallback
|
|
||||||
self.update_interval = timedelta(seconds=self._poll_interval)
|
|
||||||
_LOGGER.warning("WebSocket failed, falling back to polling")
|
|
||||||
# Schedule reconnect attempt
|
|
||||||
self._schedule_reconnect()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _handle_ws_status_update(self, status_data: dict[str, Any]) -> None:
|
|
||||||
"""Handle status update from WebSocket."""
|
|
||||||
self.async_set_updated_data(status_data)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _handle_ws_disconnect(self) -> None:
|
|
||||||
"""Handle WebSocket disconnection."""
|
|
||||||
self._ws_connected = False
|
|
||||||
# Re-enable polling as fallback
|
|
||||||
self.update_interval = timedelta(seconds=self._poll_interval)
|
|
||||||
_LOGGER.warning("WebSocket disconnected, falling back to polling")
|
|
||||||
# Schedule reconnect attempt
|
|
||||||
self._schedule_reconnect()
|
|
||||||
|
|
||||||
def _schedule_reconnect(self) -> None:
|
|
||||||
"""Schedule a WebSocket reconnection attempt."""
|
|
||||||
if self._reconnect_task and not self._reconnect_task.done():
|
|
||||||
return # Already scheduled
|
|
||||||
|
|
||||||
async def reconnect() -> None:
|
|
||||||
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
|
|
||||||
if self._use_websocket and not self._ws_connected:
|
|
||||||
_LOGGER.info("Attempting WebSocket reconnect...")
|
|
||||||
await self._connect_websocket()
|
|
||||||
|
|
||||||
self._reconnect_task = self.hass.async_create_task(reconnect())
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
|
||||||
"""Fetch data from the API (fallback when WebSocket unavailable).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Media status data
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
UpdateFailed: On API errors
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = await self.client.get_status()
|
|
||||||
_LOGGER.debug("HTTP poll received status: %s", data.get("state"))
|
|
||||||
return data
|
|
||||||
except MediaServerError as err:
|
|
||||||
raise UpdateFailed(f"Error communicating with server: {err}") from err
|
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.exception("Unexpected error fetching media status")
|
|
||||||
raise UpdateFailed(f"Unexpected error: {err}") from err
|
|
||||||
|
|
||||||
async def async_shutdown(self) -> None:
|
|
||||||
"""Clean up resources."""
|
|
||||||
if self._reconnect_task:
|
|
||||||
self._reconnect_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._reconnect_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
if self._ws_client:
|
|
||||||
await self._ws_client.disconnect()
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPlayerEntity):
|
|
||||||
"""Representation of a Remote Media Player."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
_attr_name = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
"""Return True if entity is available."""
|
|
||||||
# Use the coordinator's last_update_success to detect server availability
|
|
||||||
return self.coordinator.last_update_success
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: MediaPlayerCoordinator,
|
|
||||||
entry: ConfigEntry,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the media player entity.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
coordinator: Data update coordinator
|
|
||||||
entry: Config entry
|
|
||||||
"""
|
|
||||||
super().__init__(coordinator)
|
|
||||||
self._entry = entry
|
|
||||||
self._attr_unique_id = f"{entry.entry_id}_media_player"
|
|
||||||
|
|
||||||
# Device info - must match button.py identifiers
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, entry.entry_id)},
|
|
||||||
name=entry.title,
|
|
||||||
manufacturer="Remote Media Player",
|
|
||||||
model="Media Server",
|
|
||||||
sw_version="1.0.0",
|
|
||||||
configuration_url=f"http://{entry.data[CONF_HOST]}:{int(entry.data[CONF_PORT])}/docs",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
|
||||||
"""Return the supported features."""
|
|
||||||
return (
|
|
||||||
MediaPlayerEntityFeature.PAUSE
|
|
||||||
| MediaPlayerEntityFeature.PLAY
|
|
||||||
| MediaPlayerEntityFeature.STOP
|
|
||||||
| MediaPlayerEntityFeature.VOLUME_SET
|
|
||||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
||||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
|
||||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
|
||||||
| MediaPlayerEntityFeature.SEEK
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self) -> MediaPlayerState | None:
|
|
||||||
"""Return the state of the player."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return MediaPlayerState.OFF
|
|
||||||
|
|
||||||
state = self.coordinator.data.get("state", "idle")
|
|
||||||
state_map = {
|
|
||||||
"playing": MediaPlayerState.PLAYING,
|
|
||||||
"paused": MediaPlayerState.PAUSED,
|
|
||||||
"stopped": MediaPlayerState.IDLE,
|
|
||||||
"idle": MediaPlayerState.IDLE,
|
|
||||||
}
|
|
||||||
return state_map.get(state, MediaPlayerState.IDLE)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def volume_level(self) -> float | None:
|
|
||||||
"""Return the volume level (0..1)."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
volume = self.coordinator.data.get("volume", 0)
|
|
||||||
return volume / 100.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_volume_muted(self) -> bool | None:
|
|
||||||
"""Return True if volume is muted."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
return self.coordinator.data.get("muted", False)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_content_type(self) -> MediaType | None:
|
|
||||||
"""Return the content type of current playing media."""
|
|
||||||
return MediaType.MUSIC
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_title(self) -> str | None:
|
|
||||||
"""Return the title of current playing media."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
return self.coordinator.data.get("title")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_artist(self) -> str | None:
|
|
||||||
"""Return the artist of current playing media."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
return self.coordinator.data.get("artist")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_album_name(self) -> str | None:
|
|
||||||
"""Return the album name of current playing media."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
return self.coordinator.data.get("album")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_image_url(self) -> str | None:
|
|
||||||
"""Return the image URL of current playing media."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
return self.coordinator.data.get("album_art_url")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_duration(self) -> int | None:
|
|
||||||
"""Return the duration of current playing media in seconds."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
duration = self.coordinator.data.get("duration")
|
|
||||||
return int(duration) if duration is not None else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_position(self) -> int | None:
|
|
||||||
"""Return the position of current playing media in seconds."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
position = self.coordinator.data.get("position")
|
|
||||||
return int(position) if position is not None else None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def media_position_updated_at(self) -> datetime | None:
|
|
||||||
"""Return when the position was last updated."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
if self.coordinator.data.get("position") is not None:
|
|
||||||
# Use last_update_success_time if available, otherwise use current time
|
|
||||||
if hasattr(self.coordinator, 'last_update_success_time'):
|
|
||||||
return self.coordinator.last_update_success_time
|
|
||||||
return datetime.now()
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source(self) -> str | None:
|
|
||||||
"""Return the current media source."""
|
|
||||||
if self.coordinator.data is None:
|
|
||||||
return None
|
|
||||||
return self.coordinator.data.get("source")
|
|
||||||
|
|
||||||
async def async_media_play(self) -> None:
|
|
||||||
"""Send play command."""
|
|
||||||
try:
|
|
||||||
await self.coordinator.client.play()
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to play: %s", err)
|
|
||||||
|
|
||||||
async def async_media_pause(self) -> None:
|
|
||||||
"""Send pause command."""
|
|
||||||
try:
|
|
||||||
await self.coordinator.client.pause()
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to pause: %s", err)
|
|
||||||
|
|
||||||
async def async_media_stop(self) -> None:
|
|
||||||
"""Send stop command."""
|
|
||||||
try:
|
|
||||||
await self.coordinator.client.stop()
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to stop: %s", err)
|
|
||||||
|
|
||||||
async def async_media_next_track(self) -> None:
|
|
||||||
"""Send next track command."""
|
|
||||||
try:
|
|
||||||
await self.coordinator.client.next_track()
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to skip to next track: %s", err)
|
|
||||||
|
|
||||||
async def async_media_previous_track(self) -> None:
|
|
||||||
"""Send previous track command."""
|
|
||||||
try:
|
|
||||||
await self.coordinator.client.previous_track()
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to go to previous track: %s", err)
|
|
||||||
|
|
||||||
async def async_set_volume_level(self, volume: float) -> None:
|
|
||||||
"""Set volume level, range 0..1."""
|
|
||||||
try:
|
|
||||||
await self.coordinator.client.set_volume(int(volume * 100))
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to set volume: %s", err)
|
|
||||||
|
|
||||||
async def async_mute_volume(self, mute: bool) -> None:
|
|
||||||
"""Mute/unmute the volume."""
|
|
||||||
try:
|
|
||||||
# Toggle mute (API toggles, so call it if state differs)
|
|
||||||
if self.is_volume_muted != mute:
|
|
||||||
await self.coordinator.client.toggle_mute()
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to toggle mute: %s", err)
|
|
||||||
|
|
||||||
async def async_media_seek(self, position: float) -> None:
|
|
||||||
"""Seek to a position."""
|
|
||||||
try:
|
|
||||||
await self.coordinator.client.seek(position)
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to seek: %s", err)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
execute_script:
|
|
||||||
name: Execute Script
|
|
||||||
description: Execute a pre-defined script on the media server
|
|
||||||
fields:
|
|
||||||
script_name:
|
|
||||||
name: Script Name
|
|
||||||
description: Name of the script to execute (as defined in server config)
|
|
||||||
required: true
|
|
||||||
example: "launch_spotify"
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
args:
|
|
||||||
name: Arguments
|
|
||||||
description: Optional list of arguments to pass to the script
|
|
||||||
required: false
|
|
||||||
example: '["arg1", "arg2"]'
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"step": {
|
|
||||||
"user": {
|
|
||||||
"title": "Connect to Media Server",
|
|
||||||
"description": "Enter the connection details for your Media Server.",
|
|
||||||
"data": {
|
|
||||||
"host": "Host",
|
|
||||||
"port": "Port",
|
|
||||||
"token": "API Token",
|
|
||||||
"name": "Name",
|
|
||||||
"poll_interval": "Poll Interval"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "Hostname or IP address of the Media Server",
|
|
||||||
"port": "Port number (default: 8765)",
|
|
||||||
"token": "API authentication token from the server configuration",
|
|
||||||
"name": "Display name for this media player",
|
|
||||||
"poll_interval": "How often to poll for status updates (seconds)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "Failed to connect to the Media Server. Please check the host and port.",
|
|
||||||
"invalid_auth": "Invalid API token. Please check your token.",
|
|
||||||
"unknown": "An unexpected error occurred."
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "This Media Server is already configured."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"step": {
|
|
||||||
"init": {
|
|
||||||
"title": "Options",
|
|
||||||
"data": {
|
|
||||||
"poll_interval": "Poll Interval"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"poll_interval": "How often to poll for status updates (seconds)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"execute_script": {
|
|
||||||
"name": "Execute Script",
|
|
||||||
"description": "Execute a pre-defined script on the media server.",
|
|
||||||
"fields": {
|
|
||||||
"script_name": {
|
|
||||||
"name": "Script Name",
|
|
||||||
"description": "Name of the script to execute (as defined in server config)"
|
|
||||||
},
|
|
||||||
"args": {
|
|
||||||
"name": "Arguments",
|
|
||||||
"description": "Optional list of arguments to pass to the script"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"step": {
|
|
||||||
"user": {
|
|
||||||
"title": "Connect to Media Server",
|
|
||||||
"description": "Enter the connection details for your Media Server.",
|
|
||||||
"data": {
|
|
||||||
"host": "Host",
|
|
||||||
"port": "Port",
|
|
||||||
"token": "API Token",
|
|
||||||
"name": "Name",
|
|
||||||
"poll_interval": "Poll Interval"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "Hostname or IP address of the Media Server",
|
|
||||||
"port": "Port number (default: 8765)",
|
|
||||||
"token": "API authentication token from the server configuration",
|
|
||||||
"name": "Display name for this media player",
|
|
||||||
"poll_interval": "How often to poll for status updates (seconds)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "Failed to connect to the Media Server. Please check the host and port.",
|
|
||||||
"invalid_auth": "Invalid API token. Please check your token.",
|
|
||||||
"unknown": "An unexpected error occurred."
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "This Media Server is already configured."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"step": {
|
|
||||||
"init": {
|
|
||||||
"title": "Options",
|
|
||||||
"data": {
|
|
||||||
"poll_interval": "Poll Interval"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"poll_interval": "How often to poll for status updates (seconds)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"execute_script": {
|
|
||||||
"name": "Execute Script",
|
|
||||||
"description": "Execute a pre-defined script on the media server.",
|
|
||||||
"fields": {
|
|
||||||
"script_name": {
|
|
||||||
"name": "Script Name",
|
|
||||||
"description": "Name of the script to execute (as defined in server config)"
|
|
||||||
},
|
|
||||||
"args": {
|
|
||||||
"name": "Arguments",
|
|
||||||
"description": "Optional list of arguments to pass to the script"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"step": {
|
|
||||||
"user": {
|
|
||||||
"title": "Подключение к Media Server",
|
|
||||||
"description": "Введите данные для подключения к Media Server.",
|
|
||||||
"data": {
|
|
||||||
"host": "Хост",
|
|
||||||
"port": "Порт",
|
|
||||||
"token": "API токен",
|
|
||||||
"name": "Название",
|
|
||||||
"poll_interval": "Интервал опроса"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "Имя хоста или IP-адрес Media Server",
|
|
||||||
"port": "Номер порта (по умолчанию: 8765)",
|
|
||||||
"token": "Токен аутентификации из конфигурации сервера",
|
|
||||||
"name": "Отображаемое имя медиаплеера",
|
|
||||||
"poll_interval": "Частота опроса статуса (в секундах)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "Не удалось подключиться к Media Server. Проверьте хост и порт.",
|
|
||||||
"invalid_auth": "Неверный API токен. Проверьте токен.",
|
|
||||||
"unknown": "Произошла непредвиденная ошибка."
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "Этот Media Server уже настроен."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"step": {
|
|
||||||
"init": {
|
|
||||||
"title": "Настройки",
|
|
||||||
"data": {
|
|
||||||
"poll_interval": "Интервал опроса"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"poll_interval": "Частота опроса статуса (в секундах)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"execute_script": {
|
|
||||||
"name": "Выполнить скрипт",
|
|
||||||
"description": "Выполнить предопределённый скрипт на медиасервере.",
|
|
||||||
"fields": {
|
|
||||||
"script_name": {
|
|
||||||
"name": "Имя скрипта",
|
|
||||||
"description": "Имя скрипта для выполнения (из конфигурации сервера)"
|
|
||||||
},
|
|
||||||
"args": {
|
|
||||||
"name": "Аргументы",
|
|
||||||
"description": "Необязательный список аргументов для передачи скрипту"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Remote Media Player",
|
|
||||||
"render_readme": true,
|
|
||||||
"homeassistant": "2024.1.0"
|
|
||||||
}
|
|
||||||
1
media-server
Submodule
1
media-server
Submodule
Submodule media-server added at 83acf5f1ec
48
media-server/.gitignore
vendored
48
media-server/.gitignore
vendored
@@ -1,48 +0,0 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env/
|
|
||||||
.venv/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
.claude/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Config files with secrets
|
|
||||||
config.yaml
|
|
||||||
config.json
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
logs/
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Media Server - Development Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Standalone REST API server (FastAPI) for controlling system-wide media playback on Windows, Linux, macOS, and Android.
|
|
||||||
|
|
||||||
## Running the Server
|
|
||||||
|
|
||||||
### Manual Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m media_server.main
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auto-Start on Boot (Windows Task Scheduler)
|
|
||||||
|
|
||||||
Run in **Administrator PowerShell** from the media-server directory:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\media_server\service\install_task_windows.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
To remove the scheduled task:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Copy `config.example.yaml` to `config.yaml` and customize.
|
|
||||||
|
|
||||||
The API token is generated on first run and displayed in the console output.
|
|
||||||
|
|
||||||
Default port: `8765`
|
|
||||||
|
|
||||||
## Git Rules
|
|
||||||
|
|
||||||
Always ask for user approval before committing changes to git.
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
# Media Server
|
|
||||||
|
|
||||||
A REST API server for controlling system media playback on Windows, Linux, macOS, and Android.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Control any media player via system-wide media transport controls
|
|
||||||
- Play/Pause/Stop/Next/Previous track
|
|
||||||
- Volume control and mute
|
|
||||||
- Seek within tracks
|
|
||||||
- Get current track info (title, artist, album, artwork)
|
|
||||||
- Token-based authentication
|
|
||||||
- Cross-platform support
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Python 3.10+
|
|
||||||
- Platform-specific dependencies (see below)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes`
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install system dependencies
|
|
||||||
sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
No additional dependencies - uses built-in `osascript`.
|
|
||||||
|
|
||||||
### Android (Termux)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In Termux
|
|
||||||
pkg install python termux-api
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Requires Termux and Termux:API apps from F-Droid.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. Generate configuration with API token:
|
|
||||||
```bash
|
|
||||||
python -m media_server.main --generate-config
|
|
||||||
```
|
|
||||||
|
|
||||||
2. View your API token:
|
|
||||||
```bash
|
|
||||||
python -m media_server.main --show-token
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start the server:
|
|
||||||
```bash
|
|
||||||
python -m media_server.main
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Test the connection:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8765/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Test with authentication:
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Configuration file locations:
|
|
||||||
- Windows: `%APPDATA%\media-server\config.yaml`
|
|
||||||
- Linux/macOS: `~/.config/media-server/config.yaml`
|
|
||||||
|
|
||||||
### config.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
host: 0.0.0.0
|
|
||||||
port: 8765
|
|
||||||
api_token: your-secret-token-here
|
|
||||||
poll_interval: 1.0
|
|
||||||
log_level: INFO
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export MEDIA_SERVER_HOST=0.0.0.0
|
|
||||||
export MEDIA_SERVER_PORT=8765
|
|
||||||
export MEDIA_SERVER_API_TOKEN=your-token
|
|
||||||
export MEDIA_SERVER_LOG_LEVEL=DEBUG
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
No authentication required. Returns server status and platform info.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"platform": "Windows",
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Media Status
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/media/status
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"state": "playing",
|
|
||||||
"title": "Song Title",
|
|
||||||
"artist": "Artist Name",
|
|
||||||
"album": "Album Name",
|
|
||||||
"album_art_url": "https://...",
|
|
||||||
"duration": 240.5,
|
|
||||||
"position": 120.3,
|
|
||||||
"volume": 75,
|
|
||||||
"muted": false,
|
|
||||||
"source": "Spotify"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Media Controls
|
|
||||||
|
|
||||||
All control endpoints require authentication and return `{"success": true}` on success.
|
|
||||||
|
|
||||||
| Endpoint | Method | Body | Description |
|
|
||||||
|----------|--------|------|-------------|
|
|
||||||
| `/api/media/play` | POST | - | Resume playback |
|
|
||||||
| `/api/media/pause` | POST | - | Pause playback |
|
|
||||||
| `/api/media/stop` | POST | - | Stop playback |
|
|
||||||
| `/api/media/next` | POST | - | Next track |
|
|
||||||
| `/api/media/previous` | POST | - | Previous track |
|
|
||||||
| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
|
|
||||||
| `/api/media/mute` | POST | - | Toggle mute |
|
|
||||||
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) |
|
|
||||||
|
|
||||||
### Script Execution
|
|
||||||
|
|
||||||
The server supports executing pre-defined scripts via API.
|
|
||||||
|
|
||||||
#### List Scripts
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/scripts/list
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "lock_screen",
|
|
||||||
"label": "Lock Screen",
|
|
||||||
"description": "Lock the workstation",
|
|
||||||
"timeout": 5
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Execute Script
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/scripts/execute/{script_name}
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{"args": []}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"script": "lock_screen",
|
|
||||||
"exit_code": 0,
|
|
||||||
"stdout": "",
|
|
||||||
"stderr": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuring Scripts
|
|
||||||
|
|
||||||
Add scripts in your `config.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
scripts:
|
|
||||||
lock_screen:
|
|
||||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
|
||||||
label: "Lock Screen"
|
|
||||||
description: "Lock the workstation"
|
|
||||||
timeout: 5
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
shutdown:
|
|
||||||
command: "shutdown /s /t 0"
|
|
||||||
label: "Shutdown"
|
|
||||||
description: "Shutdown the PC immediately"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
restart:
|
|
||||||
command: "shutdown /r /t 0"
|
|
||||||
label: "Restart"
|
|
||||||
description: "Restart the PC"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
hibernate:
|
|
||||||
command: "shutdown /h"
|
|
||||||
label: "Hibernate"
|
|
||||||
description: "Hibernate the PC"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
sleep:
|
|
||||||
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
|
|
||||||
label: "Sleep"
|
|
||||||
description: "Put PC to sleep"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Script configuration options:
|
|
||||||
|
|
||||||
| Field | Required | Description |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| `command` | Yes | Command to execute |
|
|
||||||
| `label` | No | User-friendly display name (defaults to script name) |
|
|
||||||
| `description` | No | Description of what the script does |
|
|
||||||
| `icon` | No | Custom MDI icon (e.g., `mdi:power`) |
|
|
||||||
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
|
|
||||||
| `working_dir` | No | Working directory for the command |
|
|
||||||
| `shell` | No | Run in shell (default: true) |
|
|
||||||
|
|
||||||
## Running as a Service
|
|
||||||
|
|
||||||
### Windows Task Scheduler (Recommended)
|
|
||||||
|
|
||||||
Run in **Administrator PowerShell** from the project root:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\media_server\service\install_task_windows.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
To remove the scheduled task:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows Service
|
|
||||||
|
|
||||||
Install:
|
|
||||||
```bash
|
|
||||||
python -m media_server.service.install_windows install
|
|
||||||
```
|
|
||||||
|
|
||||||
Start/Stop:
|
|
||||||
```bash
|
|
||||||
python -m media_server.service.install_windows start
|
|
||||||
python -m media_server.service.install_windows stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove:
|
|
||||||
```bash
|
|
||||||
python -m media_server.service.install_windows remove
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux (systemd)
|
|
||||||
|
|
||||||
Install:
|
|
||||||
```bash
|
|
||||||
sudo ./service/install_linux.sh install
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable and start for your user:
|
|
||||||
```bash
|
|
||||||
sudo systemctl enable media-server@$USER
|
|
||||||
sudo systemctl start media-server@$USER
|
|
||||||
```
|
|
||||||
|
|
||||||
View logs:
|
|
||||||
```bash
|
|
||||||
journalctl -u media-server@$USER -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## Command Line Options
|
|
||||||
|
|
||||||
```
|
|
||||||
python -m media_server.main [OPTIONS]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--host TEXT Host to bind to (default: 0.0.0.0)
|
|
||||||
--port INTEGER Port to bind to (default: 8765)
|
|
||||||
--generate-config Generate default config file and exit
|
|
||||||
--show-token Show current API token and exit
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Recommendations
|
|
||||||
|
|
||||||
1. **Use HTTPS in production** - Set up a reverse proxy (nginx, Caddy) with SSL
|
|
||||||
2. **Strong tokens** - Default tokens are 32 random characters; don't use weak tokens
|
|
||||||
3. **Firewall** - Only expose the port to trusted networks
|
|
||||||
4. **Secrets management** - Don't commit tokens to version control
|
|
||||||
|
|
||||||
## Supported Media Players
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
- Spotify
|
|
||||||
- Windows Media Player
|
|
||||||
- VLC
|
|
||||||
- Groove Music
|
|
||||||
- Web browsers (Chrome, Edge, Firefox)
|
|
||||||
- Any app using Windows Media Transport Controls
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
- Any MPRIS-compliant player:
|
|
||||||
- Spotify
|
|
||||||
- VLC
|
|
||||||
- Rhythmbox
|
|
||||||
- Clementine
|
|
||||||
- Web browsers
|
|
||||||
- MPD (with MPRIS bridge)
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
- Spotify
|
|
||||||
- Apple Music
|
|
||||||
- VLC (partial)
|
|
||||||
- QuickTime Player
|
|
||||||
|
|
||||||
### Android (via Termux)
|
|
||||||
- System media controls
|
|
||||||
- Limited seek support
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "No active media session"
|
|
||||||
- Ensure a media player is running and has played content
|
|
||||||
- On Windows, check that the app supports media transport controls
|
|
||||||
- On Linux, verify MPRIS with: `dbus-send --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.ListNames | grep mpris`
|
|
||||||
|
|
||||||
### Permission errors on Linux
|
|
||||||
- Ensure your user has access to the D-Bus session bus
|
|
||||||
- For systemd service, the `DBUS_SESSION_BUS_ADDRESS` must be set correctly
|
|
||||||
|
|
||||||
### Volume control not working
|
|
||||||
- Windows: Run as administrator if needed
|
|
||||||
- Linux: Ensure PulseAudio/PipeWire is running
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Media Server Configuration
|
|
||||||
# Copy this file to config.yaml and customize as needed.
|
|
||||||
# A secure token will be auto-generated on first run if not specified.
|
|
||||||
|
|
||||||
# API Token (generate a secure random token)
|
|
||||||
api_token: "your-secure-token-here"
|
|
||||||
|
|
||||||
# Server settings
|
|
||||||
host: "0.0.0.0"
|
|
||||||
port: 8765
|
|
||||||
|
|
||||||
# Custom scripts
|
|
||||||
scripts:
|
|
||||||
lock_screen:
|
|
||||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
|
||||||
label: "Lock Screen"
|
|
||||||
description: "Lock the workstation"
|
|
||||||
timeout: 5
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
hibernate:
|
|
||||||
command: "shutdown /h"
|
|
||||||
label: "Hibernate"
|
|
||||||
description: "Hibernate the PC"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
sleep:
|
|
||||||
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
|
|
||||||
label: "Sleep"
|
|
||||||
description: "Put PC to sleep"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
shutdown:
|
|
||||||
command: "shutdown /s /t 0"
|
|
||||||
label: "Shutdown"
|
|
||||||
description: "Shutdown the PC immediately"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
restart:
|
|
||||||
command: "shutdown /r /t 0"
|
|
||||||
label: "Restart"
|
|
||||||
description: "Restart the PC immediately"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
"""Media Server - REST API for controlling system media playback."""
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""Authentication middleware and utilities."""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Query, Request, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
|
|
||||||
from .config import settings
|
|
||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
) -> str:
|
|
||||||
"""Verify the API token from the Authorization header.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The incoming request
|
|
||||||
credentials: The bearer token credentials
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The validated token
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If the token is missing or invalid
|
|
||||||
"""
|
|
||||||
if credentials is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Missing authentication token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if credentials.credentials != settings.api_token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid authentication token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
return credentials.credentials
|
|
||||||
|
|
||||||
|
|
||||||
class TokenAuth:
|
|
||||||
"""Dependency class for token authentication."""
|
|
||||||
|
|
||||||
def __init__(self, auto_error: bool = True):
|
|
||||||
self.auto_error = auto_error
|
|
||||||
|
|
||||||
async def __call__(
|
|
||||||
self,
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
) -> str | None:
|
|
||||||
"""Verify the token and return it or raise an exception."""
|
|
||||||
if credentials is None:
|
|
||||||
if self.auto_error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Missing authentication token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if credentials.credentials != settings.api_token:
|
|
||||||
if self.auto_error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid authentication token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return credentials.credentials
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_token_or_query(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
token: Optional[str] = Query(None, description="API token as query parameter"),
|
|
||||||
) -> str:
|
|
||||||
"""Verify the API token from header or query parameter.
|
|
||||||
|
|
||||||
Useful for endpoints that need to be accessed via URL (like images).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
credentials: The bearer token credentials from header
|
|
||||||
token: Token from query parameter
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The validated token
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If the token is missing or invalid
|
|
||||||
"""
|
|
||||||
# Try header first
|
|
||||||
if credentials is not None:
|
|
||||||
if credentials.credentials == settings.api_token:
|
|
||||||
return credentials.credentials
|
|
||||||
|
|
||||||
# Try query parameter
|
|
||||||
if token is not None:
|
|
||||||
if token == settings.api_token:
|
|
||||||
return token
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Missing or invalid authentication token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
"""Configuration management for the media server."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptConfig(BaseModel):
|
|
||||||
"""Configuration for a custom script."""
|
|
||||||
|
|
||||||
command: str = Field(..., description="Command or script to execute")
|
|
||||||
label: Optional[str] = Field(default=None, description="User-friendly display label")
|
|
||||||
description: str = Field(default="", description="Script description")
|
|
||||||
icon: Optional[str] = Field(default=None, description="Custom icon (e.g., 'mdi:power')")
|
|
||||||
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
|
|
||||||
working_dir: Optional[str] = Field(default=None, description="Working directory")
|
|
||||||
shell: bool = Field(default=True, description="Run command in shell")
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Application settings loaded from environment or config file."""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_prefix="MEDIA_SERVER_",
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Server settings
|
|
||||||
host: str = Field(default="0.0.0.0", description="Server bind address")
|
|
||||||
port: int = Field(default=8765, description="Server port")
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
api_token: str = Field(
|
|
||||||
default_factory=lambda: secrets.token_urlsafe(32),
|
|
||||||
description="API authentication token",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Media controller settings
|
|
||||||
poll_interval: float = Field(
|
|
||||||
default=1.0, description="Media status poll interval in seconds"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log_level: str = Field(default="INFO", description="Logging level")
|
|
||||||
|
|
||||||
# Custom scripts (loaded separately from YAML)
|
|
||||||
scripts: dict[str, ScriptConfig] = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="Custom scripts that can be executed via API",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
|
||||||
"""Load settings from a YAML configuration file."""
|
|
||||||
if path is None:
|
|
||||||
# Look for config in standard locations
|
|
||||||
search_paths = [
|
|
||||||
Path("config.yaml"),
|
|
||||||
Path("config.yml"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add platform-specific config directory
|
|
||||||
if os.name == "nt": # Windows
|
|
||||||
appdata = os.environ.get("APPDATA", "")
|
|
||||||
if appdata:
|
|
||||||
search_paths.append(Path(appdata) / "media-server" / "config.yaml")
|
|
||||||
else: # Linux/Unix/macOS
|
|
||||||
search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml")
|
|
||||||
search_paths.append(Path("/etc/media-server/config.yaml"))
|
|
||||||
|
|
||||||
for search_path in search_paths:
|
|
||||||
if search_path.exists():
|
|
||||||
path = search_path
|
|
||||||
break
|
|
||||||
|
|
||||||
if path and path.exists():
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
config_data = yaml.safe_load(f) or {}
|
|
||||||
return cls(**config_data)
|
|
||||||
|
|
||||||
return cls()
|
|
||||||
|
|
||||||
|
|
||||||
def get_config_dir() -> Path:
|
|
||||||
"""Get the configuration directory path."""
|
|
||||||
if os.name == "nt": # Windows
|
|
||||||
config_dir = Path(os.environ.get("APPDATA", "")) / "media-server"
|
|
||||||
else: # Linux/Unix
|
|
||||||
config_dir = Path.home() / ".config" / "media-server"
|
|
||||||
|
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return config_dir
|
|
||||||
|
|
||||||
|
|
||||||
def generate_default_config(path: Optional[Path] = None) -> Path:
|
|
||||||
"""Generate a default configuration file with a new API token."""
|
|
||||||
if path is None:
|
|
||||||
path = get_config_dir() / "config.yaml"
|
|
||||||
|
|
||||||
config = {
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 8765,
|
|
||||||
"api_token": secrets.token_urlsafe(32),
|
|
||||||
"poll_interval": 1.0,
|
|
||||||
"log_level": "INFO",
|
|
||||||
"scripts": {
|
|
||||||
"example_script": {
|
|
||||||
"command": "echo Hello from Media Server!",
|
|
||||||
"description": "Example script - echoes a message",
|
|
||||||
"timeout": 10,
|
|
||||||
"shell": True,
|
|
||||||
},
|
|
||||||
# Add your custom scripts here:
|
|
||||||
# "shutdown": {
|
|
||||||
# "command": "shutdown /s /t 60",
|
|
||||||
# "description": "Shutdown computer in 60 seconds",
|
|
||||||
# "timeout": 5,
|
|
||||||
# },
|
|
||||||
# "lock_screen": {
|
|
||||||
# "command": "rundll32.exe user32.dll,LockWorkStation",
|
|
||||||
# "description": "Lock the workstation",
|
|
||||||
# "timeout": 5,
|
|
||||||
# },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
||||||
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
# Global settings instance
|
|
||||||
settings = Settings.load_from_yaml()
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"""Media Server - FastAPI application entry point."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
import uvicorn
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from .config import settings, generate_default_config, get_config_dir
|
|
||||||
from .routes import health_router, media_router, scripts_router
|
|
||||||
from .services import get_media_controller
|
|
||||||
from .services.websocket_manager import ws_manager
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
|
||||||
"""Configure application logging."""
|
|
||||||
logging.basicConfig(
|
|
||||||
level=getattr(logging, settings.log_level.upper()),
|
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
||||||
handlers=[logging.StreamHandler(sys.stdout)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
"""Application lifespan handler."""
|
|
||||||
setup_logging()
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
|
||||||
logger.info(f"API Token: {settings.api_token[:8]}...")
|
|
||||||
|
|
||||||
# Start WebSocket status monitor
|
|
||||||
controller = get_media_controller()
|
|
||||||
await ws_manager.start_status_monitor(controller.get_status)
|
|
||||||
logger.info("WebSocket status monitor started")
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
# Stop WebSocket status monitor
|
|
||||||
await ws_manager.stop_status_monitor()
|
|
||||||
logger.info("Media Server shutting down")
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
|
||||||
"""Create and configure the FastAPI application."""
|
|
||||||
app = FastAPI(
|
|
||||||
title="Media Server",
|
|
||||||
description="REST API for controlling system media playback",
|
|
||||||
version="1.0.0",
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add CORS middleware for cross-origin requests
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register routers
|
|
||||||
app.include_router(health_router)
|
|
||||||
app.include_router(media_router)
|
|
||||||
app.include_router(scripts_router)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point for running the server."""
|
|
||||||
parser = argparse.ArgumentParser(description="Media Server")
|
|
||||||
parser.add_argument(
|
|
||||||
"--host",
|
|
||||||
default=settings.host,
|
|
||||||
help=f"Host to bind to (default: {settings.host})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--port",
|
|
||||||
type=int,
|
|
||||||
default=settings.port,
|
|
||||||
help=f"Port to bind to (default: {settings.port})",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--generate-config",
|
|
||||||
action="store_true",
|
|
||||||
help="Generate a default configuration file and exit",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--show-token",
|
|
||||||
action="store_true",
|
|
||||||
help="Show the current API token and exit",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.generate_config:
|
|
||||||
config_path = generate_default_config()
|
|
||||||
print(f"Configuration file generated at: {config_path}")
|
|
||||||
print(f"API Token has been saved to the config file.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if args.show_token:
|
|
||||||
print(f"API Token: {settings.api_token}")
|
|
||||||
print(f"Config directory: {get_config_dir()}")
|
|
||||||
return
|
|
||||||
|
|
||||||
uvicorn.run(
|
|
||||||
"media_server.main:app",
|
|
||||||
host=args.host,
|
|
||||||
port=args.port,
|
|
||||||
reload=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Pydantic models for the media server API."""
|
|
||||||
|
|
||||||
from .media import (
|
|
||||||
MediaState,
|
|
||||||
MediaStatus,
|
|
||||||
VolumeRequest,
|
|
||||||
SeekRequest,
|
|
||||||
MediaInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"MediaState",
|
|
||||||
"MediaStatus",
|
|
||||||
"VolumeRequest",
|
|
||||||
"SeekRequest",
|
|
||||||
"MediaInfo",
|
|
||||||
]
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""Media-related Pydantic models."""
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class MediaState(str, Enum):
|
|
||||||
"""Playback state enumeration."""
|
|
||||||
|
|
||||||
PLAYING = "playing"
|
|
||||||
PAUSED = "paused"
|
|
||||||
STOPPED = "stopped"
|
|
||||||
IDLE = "idle"
|
|
||||||
|
|
||||||
|
|
||||||
class MediaInfo(BaseModel):
|
|
||||||
"""Information about the currently playing media."""
|
|
||||||
|
|
||||||
title: Optional[str] = Field(None, description="Track/media title")
|
|
||||||
artist: Optional[str] = Field(None, description="Artist name")
|
|
||||||
album: Optional[str] = Field(None, description="Album name")
|
|
||||||
album_art_url: Optional[str] = Field(None, description="URL to album artwork")
|
|
||||||
duration: Optional[float] = Field(
|
|
||||||
None, description="Total duration in seconds", ge=0
|
|
||||||
)
|
|
||||||
position: Optional[float] = Field(
|
|
||||||
None, description="Current position in seconds", ge=0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MediaStatus(BaseModel):
|
|
||||||
"""Complete media playback status."""
|
|
||||||
|
|
||||||
state: MediaState = Field(default=MediaState.IDLE, description="Playback state")
|
|
||||||
title: Optional[str] = Field(None, description="Track/media title")
|
|
||||||
artist: Optional[str] = Field(None, description="Artist name")
|
|
||||||
album: Optional[str] = Field(None, description="Album name")
|
|
||||||
album_art_url: Optional[str] = Field(None, description="URL to album artwork")
|
|
||||||
duration: Optional[float] = Field(
|
|
||||||
None, description="Total duration in seconds", ge=0
|
|
||||||
)
|
|
||||||
position: Optional[float] = Field(
|
|
||||||
None, description="Current position in seconds", ge=0
|
|
||||||
)
|
|
||||||
volume: int = Field(default=100, description="Volume level (0-100)", ge=0, le=100)
|
|
||||||
muted: bool = Field(default=False, description="Whether audio is muted")
|
|
||||||
source: Optional[str] = Field(None, description="Media source/player name")
|
|
||||||
|
|
||||||
|
|
||||||
class VolumeRequest(BaseModel):
|
|
||||||
"""Request model for setting volume."""
|
|
||||||
|
|
||||||
volume: int = Field(..., description="Volume level (0-100)", ge=0, le=100)
|
|
||||||
|
|
||||||
|
|
||||||
class SeekRequest(BaseModel):
|
|
||||||
"""Request model for seeking to a position."""
|
|
||||||
|
|
||||||
position: float = Field(..., description="Position in seconds to seek to", ge=0)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"""API route modules."""
|
|
||||||
|
|
||||||
from .health import router as health_router
|
|
||||||
from .media import router as media_router
|
|
||||||
from .scripts import router as scripts_router
|
|
||||||
|
|
||||||
__all__ = ["health_router", "media_router", "scripts_router"]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Health check endpoint."""
|
|
||||||
|
|
||||||
import platform
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["health"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
|
||||||
async def health_check() -> dict[str, Any]:
|
|
||||||
"""Health check endpoint - no authentication required.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Health status and server information
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"status": "healthy",
|
|
||||||
"platform": platform.system(),
|
|
||||||
"version": "1.0.0",
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"""Media control API endpoints."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
|
||||||
from fastapi import status
|
|
||||||
from fastapi.responses import Response
|
|
||||||
|
|
||||||
from ..auth import verify_token, verify_token_or_query
|
|
||||||
from ..config import settings
|
|
||||||
from ..models import MediaStatus, VolumeRequest, SeekRequest
|
|
||||||
from ..services import get_media_controller, get_current_album_art
|
|
||||||
from ..services.websocket_manager import ws_manager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/media", tags=["media"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=MediaStatus)
|
|
||||||
async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus:
|
|
||||||
"""Get current media playback status.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Current playback state, media info, volume, etc.
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
return await controller.get_status()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/play")
|
|
||||||
async def play(_: str = Depends(verify_token)) -> dict:
|
|
||||||
"""Resume or start playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success status
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
success = await controller.play()
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Failed to start playback - no active media session",
|
|
||||||
)
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
|
||||||
async def pause(_: str = Depends(verify_token)) -> dict:
|
|
||||||
"""Pause playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success status
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
success = await controller.pause()
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Failed to pause - no active media session",
|
|
||||||
)
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop")
|
|
||||||
async def stop(_: str = Depends(verify_token)) -> dict:
|
|
||||||
"""Stop playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success status
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
success = await controller.stop()
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Failed to stop - no active media session",
|
|
||||||
)
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/next")
|
|
||||||
async def next_track(_: str = Depends(verify_token)) -> dict:
|
|
||||||
"""Skip to next track.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success status
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
success = await controller.next_track()
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Failed to skip - no active media session",
|
|
||||||
)
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/previous")
|
|
||||||
async def previous_track(_: str = Depends(verify_token)) -> dict:
|
|
||||||
"""Go to previous track.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success status
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
success = await controller.previous_track()
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Failed to go back - no active media session",
|
|
||||||
)
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/volume")
|
|
||||||
async def set_volume(
|
|
||||||
request: VolumeRequest, _: str = Depends(verify_token)
|
|
||||||
) -> dict:
|
|
||||||
"""Set the system volume.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Volume level (0-100)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success status with new volume level
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
success = await controller.set_volume(request.volume)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Failed to set volume",
|
|
||||||
)
|
|
||||||
return {"success": True, "volume": request.volume}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mute")
|
|
||||||
async def toggle_mute(_: str = Depends(verify_token)) -> dict:
|
|
||||||
"""Toggle mute state.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success status with new mute state
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
muted = await controller.toggle_mute()
|
|
||||||
return {"success": True, "muted": muted}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/seek")
|
|
||||||
async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict:
|
|
||||||
"""Seek to a position in the current track.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: Position in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Success status
|
|
||||||
"""
|
|
||||||
controller = get_media_controller()
|
|
||||||
success = await controller.seek(request.position)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="Failed to seek - no active media session or seek not supported",
|
|
||||||
)
|
|
||||||
return {"success": True, "position": request.position}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/artwork")
|
|
||||||
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
|
||||||
"""Get the current album artwork.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The album art image as PNG/JPEG
|
|
||||||
"""
|
|
||||||
art_bytes = get_current_album_art()
|
|
||||||
if art_bytes is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="No album artwork available",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to detect image type from magic bytes
|
|
||||||
content_type = "image/png" # Default
|
|
||||||
if art_bytes[:3] == b"\xff\xd8\xff":
|
|
||||||
content_type = "image/jpeg"
|
|
||||||
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
|
||||||
content_type = "image/png"
|
|
||||||
elif art_bytes[:4] == b"RIFF" and art_bytes[8:12] == b"WEBP":
|
|
||||||
content_type = "image/webp"
|
|
||||||
|
|
||||||
return Response(content=art_bytes, media_type=content_type)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws")
|
|
||||||
async def websocket_endpoint(
|
|
||||||
websocket: WebSocket,
|
|
||||||
token: str = Query(..., description="API authentication token"),
|
|
||||||
) -> None:
|
|
||||||
"""WebSocket endpoint for real-time media status updates.
|
|
||||||
|
|
||||||
Authentication is done via query parameter since WebSocket
|
|
||||||
doesn't support custom headers in the browser.
|
|
||||||
|
|
||||||
Messages sent to client:
|
|
||||||
- {"type": "status", "data": {...}} - Initial status on connect
|
|
||||||
- {"type": "status_update", "data": {...}} - Status changes
|
|
||||||
- {"type": "error", "message": "..."} - Error messages
|
|
||||||
|
|
||||||
Client can send:
|
|
||||||
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
|
|
||||||
- {"type": "get_status"} - Request current status
|
|
||||||
"""
|
|
||||||
# Verify token
|
|
||||||
if token != settings.api_token:
|
|
||||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
|
||||||
return
|
|
||||||
|
|
||||||
await ws_manager.connect(websocket)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
# Wait for messages from client (for keepalive/ping)
|
|
||||||
data = await websocket.receive_json()
|
|
||||||
|
|
||||||
if data.get("type") == "ping":
|
|
||||||
await websocket.send_json({"type": "pong"})
|
|
||||||
elif data.get("type") == "get_status":
|
|
||||||
# Allow manual status request
|
|
||||||
controller = get_media_controller()
|
|
||||||
status_data = await controller.get_status()
|
|
||||||
await websocket.send_json({
|
|
||||||
"type": "status",
|
|
||||||
"data": status_data.model_dump(),
|
|
||||||
})
|
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
await ws_manager.disconnect(websocket)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("WebSocket error: %s", e)
|
|
||||||
await ws_manager.disconnect(websocket)
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"""Script execution API endpoints."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from ..auth import verify_token
|
|
||||||
from ..config import settings
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptExecuteRequest(BaseModel):
|
|
||||||
"""Request model for script execution with optional arguments."""
|
|
||||||
|
|
||||||
args: list[str] = Field(default_factory=list, description="Additional arguments")
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptExecuteResponse(BaseModel):
|
|
||||||
"""Response model for script execution."""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
script: str
|
|
||||||
exit_code: int | None = None
|
|
||||||
stdout: str = ""
|
|
||||||
stderr: str = ""
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptInfo(BaseModel):
|
|
||||||
"""Information about an available script."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
label: str
|
|
||||||
description: str
|
|
||||||
icon: str | None = None
|
|
||||||
timeout: int
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list")
|
|
||||||
async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
|
|
||||||
"""List all available scripts.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of available scripts with their descriptions
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
ScriptInfo(
|
|
||||||
name=name,
|
|
||||||
label=config.label or name.replace("_", " ").title(),
|
|
||||||
description=config.description,
|
|
||||||
icon=config.icon,
|
|
||||||
timeout=config.timeout,
|
|
||||||
)
|
|
||||||
for name, config in settings.scripts.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/execute/{script_name}")
|
|
||||||
async def execute_script(
|
|
||||||
script_name: str,
|
|
||||||
request: ScriptExecuteRequest | None = None,
|
|
||||||
_: str = Depends(verify_token),
|
|
||||||
) -> ScriptExecuteResponse:
|
|
||||||
"""Execute a pre-defined script by name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
script_name: Name of the script to execute (must be defined in config)
|
|
||||||
request: Optional arguments to pass to the script
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Execution result including stdout, stderr, and exit code
|
|
||||||
"""
|
|
||||||
# Check if script exists
|
|
||||||
if script_name not in settings.scripts:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
|
|
||||||
)
|
|
||||||
|
|
||||||
script_config = settings.scripts[script_name]
|
|
||||||
args = request.args if request else []
|
|
||||||
|
|
||||||
logger.info(f"Executing script: {script_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build command
|
|
||||||
command = script_config.command
|
|
||||||
if args:
|
|
||||||
# Append arguments to command
|
|
||||||
command = f"{command} {' '.join(args)}"
|
|
||||||
|
|
||||||
# Execute in thread pool to not block
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
result = await loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
lambda: _run_script(
|
|
||||||
command=command,
|
|
||||||
timeout=script_config.timeout,
|
|
||||||
shell=script_config.shell,
|
|
||||||
working_dir=script_config.working_dir,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return ScriptExecuteResponse(
|
|
||||||
success=result["exit_code"] == 0,
|
|
||||||
script=script_name,
|
|
||||||
exit_code=result["exit_code"],
|
|
||||||
stdout=result["stdout"],
|
|
||||||
stderr=result["stderr"],
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Script execution error: {e}")
|
|
||||||
return ScriptExecuteResponse(
|
|
||||||
success=False,
|
|
||||||
script=script_name,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_script(
|
|
||||||
command: str,
|
|
||||||
timeout: int,
|
|
||||||
shell: bool,
|
|
||||||
working_dir: str | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Run a script synchronously.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
command: Command to execute
|
|
||||||
timeout: Timeout in seconds
|
|
||||||
shell: Whether to run in shell
|
|
||||||
working_dir: Working directory
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with exit_code, stdout, stderr
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
shell=shell,
|
|
||||||
cwd=working_dir,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"exit_code": result.returncode,
|
|
||||||
"stdout": result.stdout[:10000], # Limit output size
|
|
||||||
"stderr": result.stderr[:10000],
|
|
||||||
}
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return {
|
|
||||||
"exit_code": -1,
|
|
||||||
"stdout": "",
|
|
||||||
"stderr": f"Script timed out after {timeout} seconds",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"exit_code": -1,
|
|
||||||
"stdout": "",
|
|
||||||
"stderr": str(e),
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Linux service installation script for Media Server
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SERVICE_NAME="media-server"
|
|
||||||
INSTALL_DIR="/opt/media-server"
|
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}@.service"
|
|
||||||
CURRENT_USER=$(whoami)
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
echo_info() {
|
|
||||||
echo -e "${GREEN}[INFO]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo_warn() {
|
|
||||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo_error() {
|
|
||||||
echo -e "${RED}[ERROR]${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_root() {
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
echo_error "This script must be run as root (use sudo)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
install_dependencies() {
|
|
||||||
echo_info "Installing system dependencies..."
|
|
||||||
|
|
||||||
if command -v apt-get &> /dev/null; then
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y python3 python3-pip python3-venv python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
|
|
||||||
elif command -v dnf &> /dev/null; then
|
|
||||||
dnf install -y python3 python3-pip python3-dbus python3-gobject dbus-devel glib2-devel
|
|
||||||
elif command -v pacman &> /dev/null; then
|
|
||||||
pacman -S --noconfirm python python-pip python-dbus python-gobject
|
|
||||||
else
|
|
||||||
echo_warn "Unknown package manager. Please install dependencies manually:"
|
|
||||||
echo " - python3, python3-pip, python3-venv"
|
|
||||||
echo " - python3-dbus, python3-gi"
|
|
||||||
echo " - libdbus-1-dev, libglib2.0-dev"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
install_service() {
|
|
||||||
echo_info "Installing Media Server..."
|
|
||||||
|
|
||||||
# Create installation directory
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Copy source files
|
|
||||||
cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/"
|
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
echo_info "Creating Python virtual environment..."
|
|
||||||
python3 -m venv "$INSTALL_DIR/venv"
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
echo_info "Installing Python dependencies..."
|
|
||||||
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
|
|
||||||
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
|
|
||||||
|
|
||||||
# Install systemd service file
|
|
||||||
echo_info "Installing systemd service..."
|
|
||||||
cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE"
|
|
||||||
|
|
||||||
# Reload systemd
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
# Generate config if not exists
|
|
||||||
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
|
|
||||||
echo_info "Generating configuration file..."
|
|
||||||
sudo -u "$SUDO_USER" "$INSTALL_DIR/venv/bin/python" -m media_server.main --generate-config
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo_info "Installation complete!"
|
|
||||||
echo ""
|
|
||||||
echo "To enable and start the service for user '$SUDO_USER':"
|
|
||||||
echo " sudo systemctl enable ${SERVICE_NAME}@${SUDO_USER}"
|
|
||||||
echo " sudo systemctl start ${SERVICE_NAME}@${SUDO_USER}"
|
|
||||||
echo ""
|
|
||||||
echo "To view the API token:"
|
|
||||||
echo " cat ~/.config/media-server/config.yaml"
|
|
||||||
echo ""
|
|
||||||
echo "To view logs:"
|
|
||||||
echo " journalctl -u ${SERVICE_NAME}@${SUDO_USER} -f"
|
|
||||||
}
|
|
||||||
|
|
||||||
uninstall_service() {
|
|
||||||
echo_info "Uninstalling Media Server..."
|
|
||||||
|
|
||||||
# Stop and disable service
|
|
||||||
systemctl stop "${SERVICE_NAME}@*" 2>/dev/null || true
|
|
||||||
systemctl disable "${SERVICE_NAME}@*" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove service file
|
|
||||||
rm -f "$SERVICE_FILE"
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
# Remove installation directory
|
|
||||||
rm -rf "$INSTALL_DIR"
|
|
||||||
|
|
||||||
echo_info "Uninstallation complete!"
|
|
||||||
echo "Note: Configuration files in ~/.config/media-server were not removed."
|
|
||||||
}
|
|
||||||
|
|
||||||
show_usage() {
|
|
||||||
echo "Usage: $0 [install|uninstall|deps]"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " install Install the Media Server as a systemd service"
|
|
||||||
echo " uninstall Remove the Media Server service"
|
|
||||||
echo " deps Install system dependencies only"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main
|
|
||||||
case "${1:-}" in
|
|
||||||
install)
|
|
||||||
check_root
|
|
||||||
install_dependencies
|
|
||||||
install_service
|
|
||||||
;;
|
|
||||||
uninstall)
|
|
||||||
check_root
|
|
||||||
uninstall_service
|
|
||||||
;;
|
|
||||||
deps)
|
|
||||||
check_root
|
|
||||||
install_dependencies
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
show_usage
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# Get the project root directory (two levels up from this script)
|
|
||||||
$projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName
|
|
||||||
|
|
||||||
$action = New-ScheduledTaskAction -Execute "python" -Argument "-m media_server.main" -WorkingDirectory $projectRoot
|
|
||||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
|
||||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest
|
|
||||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
|
||||||
Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant"
|
|
||||||
|
|
||||||
Write-Host "Scheduled task 'MediaServer' created with working directory: $projectRoot"
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"""Windows service installer for Media Server.
|
|
||||||
|
|
||||||
This module allows the media server to be installed as a Windows service
|
|
||||||
that starts automatically on boot.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
Install: python -m media_server.service.install_windows install
|
|
||||||
Start: python -m media_server.service.install_windows start
|
|
||||||
Stop: python -m media_server.service.install_windows stop
|
|
||||||
Remove: python -m media_server.service.install_windows remove
|
|
||||||
Debug: python -m media_server.service.install_windows debug
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import socket
|
|
||||||
import logging
|
|
||||||
|
|
||||||
try:
|
|
||||||
import win32serviceutil
|
|
||||||
import win32service
|
|
||||||
import win32event
|
|
||||||
import servicemanager
|
|
||||||
import win32api
|
|
||||||
|
|
||||||
WIN32_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
WIN32_AVAILABLE = False
|
|
||||||
print("pywin32 not installed. Install with: pip install pywin32")
|
|
||||||
|
|
||||||
|
|
||||||
class MediaServerService:
|
|
||||||
"""Windows service wrapper for the Media Server."""
|
|
||||||
|
|
||||||
_svc_name_ = "MediaServer"
|
|
||||||
_svc_display_name_ = "Media Server"
|
|
||||||
_svc_description_ = "REST API server for controlling system media playback"
|
|
||||||
|
|
||||||
def __init__(self, args=None):
|
|
||||||
if WIN32_AVAILABLE:
|
|
||||||
win32serviceutil.ServiceFramework.__init__(self, args)
|
|
||||||
self.stop_event = win32event.CreateEvent(None, 0, 0, None)
|
|
||||||
self.is_running = False
|
|
||||||
self.server = None
|
|
||||||
|
|
||||||
def SvcStop(self):
|
|
||||||
"""Stop the service."""
|
|
||||||
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
|
|
||||||
win32event.SetEvent(self.stop_event)
|
|
||||||
self.is_running = False
|
|
||||||
if self.server:
|
|
||||||
self.server.should_exit = True
|
|
||||||
|
|
||||||
def SvcDoRun(self):
|
|
||||||
"""Run the service."""
|
|
||||||
servicemanager.LogMsg(
|
|
||||||
servicemanager.EVENTLOG_INFORMATION_TYPE,
|
|
||||||
servicemanager.PYS_SERVICE_STARTED,
|
|
||||||
(self._svc_name_, ""),
|
|
||||||
)
|
|
||||||
self.is_running = True
|
|
||||||
self.main()
|
|
||||||
|
|
||||||
def main(self):
|
|
||||||
"""Main service loop."""
|
|
||||||
import uvicorn
|
|
||||||
from media_server.main import app
|
|
||||||
from media_server.config import settings
|
|
||||||
|
|
||||||
config = uvicorn.Config(
|
|
||||||
app,
|
|
||||||
host=settings.host,
|
|
||||||
port=settings.port,
|
|
||||||
log_level=settings.log_level.lower(),
|
|
||||||
)
|
|
||||||
self.server = uvicorn.Server(config)
|
|
||||||
self.server.run()
|
|
||||||
|
|
||||||
|
|
||||||
if WIN32_AVAILABLE:
|
|
||||||
# Dynamically inherit from ServiceFramework when available
|
|
||||||
MediaServerService = type(
|
|
||||||
"MediaServerService",
|
|
||||||
(win32serviceutil.ServiceFramework,),
|
|
||||||
dict(MediaServerService.__dict__),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def install_service():
|
|
||||||
"""Install the Windows service."""
|
|
||||||
if not WIN32_AVAILABLE:
|
|
||||||
print("Error: pywin32 is required for Windows service installation")
|
|
||||||
print("Install with: pip install pywin32")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get the path to the Python executable
|
|
||||||
python_exe = sys.executable
|
|
||||||
|
|
||||||
# Get the path to this module
|
|
||||||
module_path = os.path.abspath(__file__)
|
|
||||||
|
|
||||||
win32serviceutil.InstallService(
|
|
||||||
MediaServerService._svc_name_,
|
|
||||||
MediaServerService._svc_name_,
|
|
||||||
MediaServerService._svc_display_name_,
|
|
||||||
startType=win32service.SERVICE_AUTO_START,
|
|
||||||
description=MediaServerService._svc_description_,
|
|
||||||
)
|
|
||||||
print(f"Service '{MediaServerService._svc_display_name_}' installed successfully")
|
|
||||||
print("Start the service with: sc start MediaServer")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to install service: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def remove_service():
|
|
||||||
"""Remove the Windows service."""
|
|
||||||
if not WIN32_AVAILABLE:
|
|
||||||
print("Error: pywin32 is required")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
win32serviceutil.RemoveService(MediaServerService._svc_name_)
|
|
||||||
print(f"Service '{MediaServerService._svc_display_name_}' removed successfully")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to remove service: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point for service management."""
|
|
||||||
if not WIN32_AVAILABLE:
|
|
||||||
print("Error: pywin32 is required for Windows service support")
|
|
||||||
print("Install with: pip install pywin32")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if len(sys.argv) == 1:
|
|
||||||
# Running as a service
|
|
||||||
servicemanager.Initialize()
|
|
||||||
servicemanager.PrepareToHostSingle(MediaServerService)
|
|
||||||
servicemanager.StartServiceCtrlDispatcher()
|
|
||||||
else:
|
|
||||||
# Command line management
|
|
||||||
win32serviceutil.HandleCommandLine(MediaServerService)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Media Server - REST API for controlling system media playback
|
|
||||||
After=network.target sound.target
|
|
||||||
Wants=sound.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=%i
|
|
||||||
Group=%i
|
|
||||||
|
|
||||||
# Environment variables (optional - can also use config file)
|
|
||||||
# Environment=MEDIA_SERVER_HOST=0.0.0.0
|
|
||||||
# Environment=MEDIA_SERVER_PORT=8765
|
|
||||||
# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token
|
|
||||||
|
|
||||||
# Working directory
|
|
||||||
WorkingDirectory=/opt/media-server
|
|
||||||
|
|
||||||
# Start command - adjust path to your Python environment
|
|
||||||
ExecStart=/opt/media-server/venv/bin/python -m media_server.main
|
|
||||||
|
|
||||||
# Restart policy
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=read-only
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
# Required for D-Bus access (MPRIS)
|
|
||||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Media controller services."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .media_controller import MediaController
|
|
||||||
|
|
||||||
_controller_instance: "MediaController | None" = None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_android() -> bool:
|
|
||||||
"""Check if running on Android (e.g., via Termux)."""
|
|
||||||
# Check for Android-specific paths and environment
|
|
||||||
android_indicators = [
|
|
||||||
Path("/system/build.prop").exists(),
|
|
||||||
Path("/data/data/com.termux").exists(),
|
|
||||||
"ANDROID_ROOT" in os.environ,
|
|
||||||
"TERMUX_VERSION" in os.environ,
|
|
||||||
]
|
|
||||||
return any(android_indicators)
|
|
||||||
|
|
||||||
|
|
||||||
def get_media_controller() -> "MediaController":
|
|
||||||
"""Get the platform-specific media controller instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The media controller for the current platform
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If the platform is not supported
|
|
||||||
"""
|
|
||||||
global _controller_instance
|
|
||||||
|
|
||||||
if _controller_instance is not None:
|
|
||||||
return _controller_instance
|
|
||||||
|
|
||||||
system = platform.system()
|
|
||||||
|
|
||||||
if system == "Windows":
|
|
||||||
from .windows_media import WindowsMediaController
|
|
||||||
|
|
||||||
_controller_instance = WindowsMediaController()
|
|
||||||
elif system == "Linux":
|
|
||||||
# Check if running on Android
|
|
||||||
if _is_android():
|
|
||||||
from .android_media import AndroidMediaController
|
|
||||||
|
|
||||||
_controller_instance = AndroidMediaController()
|
|
||||||
else:
|
|
||||||
from .linux_media import LinuxMediaController
|
|
||||||
|
|
||||||
_controller_instance = LinuxMediaController()
|
|
||||||
elif system == "Darwin": # macOS
|
|
||||||
from .macos_media import MacOSMediaController
|
|
||||||
|
|
||||||
_controller_instance = MacOSMediaController()
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Unsupported platform: {system}")
|
|
||||||
|
|
||||||
return _controller_instance
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_album_art() -> bytes | None:
|
|
||||||
"""Get the current album art bytes (Windows only for now)."""
|
|
||||||
system = platform.system()
|
|
||||||
if system == "Windows":
|
|
||||||
from .windows_media import get_current_album_art as _get_art
|
|
||||||
return _get_art()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["get_media_controller", "get_current_album_art"]
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
"""Android media controller using Termux:API.
|
|
||||||
|
|
||||||
This controller is designed to run on Android devices using Termux.
|
|
||||||
It requires the Termux:API app and termux-api package to be installed.
|
|
||||||
|
|
||||||
Installation:
|
|
||||||
1. Install Termux from F-Droid (not Play Store)
|
|
||||||
2. Install Termux:API from F-Droid
|
|
||||||
3. In Termux: pkg install termux-api
|
|
||||||
4. Grant necessary permissions to Termux:API
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
from typing import Optional, Any
|
|
||||||
|
|
||||||
from ..models import MediaState, MediaStatus
|
|
||||||
from .media_controller import MediaController
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_termux_api() -> bool:
|
|
||||||
"""Check if termux-api is available."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["which", "termux-media-player"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
TERMUX_API_AVAILABLE = _check_termux_api()
|
|
||||||
|
|
||||||
|
|
||||||
class AndroidMediaController(MediaController):
|
|
||||||
"""Media controller for Android using Termux:API.
|
|
||||||
|
|
||||||
Requires:
|
|
||||||
- Termux app
|
|
||||||
- Termux:API app
|
|
||||||
- termux-api package (pkg install termux-api)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
if not TERMUX_API_AVAILABLE:
|
|
||||||
logger.warning(
|
|
||||||
"Termux:API not available. Install with: pkg install termux-api"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run_termux_command(
|
|
||||||
self, command: list[str], timeout: int = 10
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Run a termux-api command and return the output."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result.stdout.strip()
|
|
||||||
logger.error(f"Termux command failed: {result.stderr}")
|
|
||||||
return None
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error(f"Termux command timed out: {command}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Termux command error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _send_media_key(self, key: str) -> bool:
|
|
||||||
"""Send a media key event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
key: One of: play, pause, play-pause, stop, next, previous
|
|
||||||
"""
|
|
||||||
# termux-media-player command
|
|
||||||
result = self._run_termux_command(["termux-media-player", key])
|
|
||||||
return result is not None
|
|
||||||
|
|
||||||
def _get_media_info(self) -> dict[str, Any]:
|
|
||||||
"""Get current media playback info using termux-media-player."""
|
|
||||||
result = self._run_termux_command(["termux-media-player", "info"])
|
|
||||||
if result:
|
|
||||||
try:
|
|
||||||
return json.loads(result)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _get_volume(self) -> tuple[int, bool]:
|
|
||||||
"""Get current volume using termux-volume."""
|
|
||||||
result = self._run_termux_command(["termux-volume"])
|
|
||||||
if result:
|
|
||||||
try:
|
|
||||||
volumes = json.loads(result)
|
|
||||||
# Find music stream
|
|
||||||
for stream in volumes:
|
|
||||||
if stream.get("stream") == "music":
|
|
||||||
volume = stream.get("volume", 0)
|
|
||||||
max_volume = stream.get("max_volume", 15)
|
|
||||||
# Convert to 0-100 scale
|
|
||||||
percent = int((volume / max_volume) * 100) if max_volume > 0 else 0
|
|
||||||
return percent, False
|
|
||||||
except (json.JSONDecodeError, KeyError):
|
|
||||||
pass
|
|
||||||
return 100, False
|
|
||||||
|
|
||||||
def _set_volume_internal(self, volume: int) -> bool:
|
|
||||||
"""Set volume using termux-volume."""
|
|
||||||
# termux-volume expects stream name and volume level
|
|
||||||
# Convert 0-100 to device scale (usually 0-15)
|
|
||||||
result = self._run_termux_command(["termux-volume"])
|
|
||||||
if result:
|
|
||||||
try:
|
|
||||||
volumes = json.loads(result)
|
|
||||||
for stream in volumes:
|
|
||||||
if stream.get("stream") == "music":
|
|
||||||
max_volume = stream.get("max_volume", 15)
|
|
||||||
device_volume = int((volume / 100) * max_volume)
|
|
||||||
self._run_termux_command(
|
|
||||||
["termux-volume", "music", str(device_volume)]
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except (json.JSONDecodeError, KeyError):
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_status(self) -> MediaStatus:
|
|
||||||
"""Get current media playback status."""
|
|
||||||
status = MediaStatus()
|
|
||||||
|
|
||||||
# Get volume
|
|
||||||
volume, muted = self._get_volume()
|
|
||||||
status.volume = volume
|
|
||||||
status.muted = muted
|
|
||||||
|
|
||||||
# Get media info
|
|
||||||
info = self._get_media_info()
|
|
||||||
if not info:
|
|
||||||
status.state = MediaState.IDLE
|
|
||||||
return status
|
|
||||||
|
|
||||||
# Parse playback status
|
|
||||||
playback_status = info.get("status", "").lower()
|
|
||||||
if playback_status == "playing":
|
|
||||||
status.state = MediaState.PLAYING
|
|
||||||
elif playback_status == "paused":
|
|
||||||
status.state = MediaState.PAUSED
|
|
||||||
elif playback_status == "stopped":
|
|
||||||
status.state = MediaState.STOPPED
|
|
||||||
else:
|
|
||||||
status.state = MediaState.IDLE
|
|
||||||
|
|
||||||
# Parse track info
|
|
||||||
status.title = info.get("title") or info.get("Track") or None
|
|
||||||
status.artist = info.get("artist") or info.get("Artist") or None
|
|
||||||
status.album = info.get("album") or info.get("Album") or None
|
|
||||||
|
|
||||||
# Duration and position (in milliseconds from some sources)
|
|
||||||
duration = info.get("duration", 0)
|
|
||||||
if duration > 1000: # Likely milliseconds
|
|
||||||
duration = duration / 1000
|
|
||||||
status.duration = duration if duration > 0 else None
|
|
||||||
|
|
||||||
position = info.get("position", info.get("current_position", 0))
|
|
||||||
if position > 1000: # Likely milliseconds
|
|
||||||
position = position / 1000
|
|
||||||
status.position = position if position > 0 else None
|
|
||||||
|
|
||||||
status.source = "Android"
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
async def play(self) -> bool:
|
|
||||||
"""Resume playback."""
|
|
||||||
return self._send_media_key("play")
|
|
||||||
|
|
||||||
async def pause(self) -> bool:
|
|
||||||
"""Pause playback."""
|
|
||||||
return self._send_media_key("pause")
|
|
||||||
|
|
||||||
async def stop(self) -> bool:
|
|
||||||
"""Stop playback."""
|
|
||||||
return self._send_media_key("stop")
|
|
||||||
|
|
||||||
async def next_track(self) -> bool:
|
|
||||||
"""Skip to next track."""
|
|
||||||
return self._send_media_key("next")
|
|
||||||
|
|
||||||
async def previous_track(self) -> bool:
|
|
||||||
"""Go to previous track."""
|
|
||||||
return self._send_media_key("previous")
|
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
|
||||||
"""Set system volume."""
|
|
||||||
return self._set_volume_internal(volume)
|
|
||||||
|
|
||||||
async def toggle_mute(self) -> bool:
|
|
||||||
"""Toggle mute state.
|
|
||||||
|
|
||||||
Note: Android doesn't have a simple mute toggle via termux-api,
|
|
||||||
so we set volume to 0 or restore previous volume.
|
|
||||||
"""
|
|
||||||
volume, _ = self._get_volume()
|
|
||||||
if volume > 0:
|
|
||||||
# Store current volume and mute
|
|
||||||
self._previous_volume = volume
|
|
||||||
self._set_volume_internal(0)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# Restore previous volume
|
|
||||||
prev = getattr(self, "_previous_volume", 50)
|
|
||||||
self._set_volume_internal(prev)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def seek(self, position: float) -> bool:
|
|
||||||
"""Seek to position in seconds.
|
|
||||||
|
|
||||||
Note: Seek functionality may be limited depending on the media player.
|
|
||||||
"""
|
|
||||||
# termux-media-player doesn't support seek directly
|
|
||||||
# This is a limitation of the API
|
|
||||||
logger.warning("Seek not fully supported on Android via Termux:API")
|
|
||||||
return False
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
"""Linux media controller using MPRIS D-Bus interface."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
from typing import Optional, Any
|
|
||||||
|
|
||||||
from ..models import MediaState, MediaStatus
|
|
||||||
from .media_controller import MediaController
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Linux-specific imports
|
|
||||||
try:
|
|
||||||
import dbus
|
|
||||||
from dbus.mainloop.glib import DBusGMainLoop
|
|
||||||
|
|
||||||
DBUS_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
DBUS_AVAILABLE = False
|
|
||||||
logger.warning("D-Bus libraries not available")
|
|
||||||
|
|
||||||
|
|
||||||
class LinuxMediaController(MediaController):
|
|
||||||
"""Media controller for Linux using MPRIS D-Bus interface."""
|
|
||||||
|
|
||||||
MPRIS_PATH = "/org/mpris/MediaPlayer2"
|
|
||||||
MPRIS_INTERFACE = "org.mpris.MediaPlayer2.Player"
|
|
||||||
MPRIS_PREFIX = "org.mpris.MediaPlayer2."
|
|
||||||
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
if not DBUS_AVAILABLE:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Linux media control requires dbus-python package. "
|
|
||||||
"Install with: sudo apt-get install python3-dbus"
|
|
||||||
)
|
|
||||||
DBusGMainLoop(set_as_default=True)
|
|
||||||
self._bus = dbus.SessionBus()
|
|
||||||
|
|
||||||
def _get_active_player(self) -> Optional[str]:
|
|
||||||
"""Find an active MPRIS media player on the bus."""
|
|
||||||
try:
|
|
||||||
bus_names = self._bus.list_names()
|
|
||||||
mpris_players = [
|
|
||||||
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
|
|
||||||
]
|
|
||||||
|
|
||||||
if not mpris_players:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Prefer players that are currently playing
|
|
||||||
for player in mpris_players:
|
|
||||||
try:
|
|
||||||
proxy = self._bus.get_object(player, self.MPRIS_PATH)
|
|
||||||
props = dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
|
|
||||||
status = props.Get(self.MPRIS_INTERFACE, "PlaybackStatus")
|
|
||||||
if status == "Playing":
|
|
||||||
return player
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Return the first available player
|
|
||||||
return mpris_players[0]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get active player: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_player_interface(self, player_name: str):
|
|
||||||
"""Get the MPRIS player interface."""
|
|
||||||
proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
|
|
||||||
return dbus.Interface(proxy, self.MPRIS_INTERFACE)
|
|
||||||
|
|
||||||
def _get_properties_interface(self, player_name: str):
|
|
||||||
"""Get the properties interface for a player."""
|
|
||||||
proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
|
|
||||||
return dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
|
|
||||||
|
|
||||||
def _get_property(self, player_name: str, property_name: str) -> Any:
|
|
||||||
"""Get a property from the player."""
|
|
||||||
try:
|
|
||||||
props = self._get_properties_interface(player_name)
|
|
||||||
return props.Get(self.MPRIS_INTERFACE, property_name)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Failed to get property {property_name}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_volume_pulseaudio(self) -> tuple[int, bool]:
|
|
||||||
"""Get volume using pactl (PulseAudio/PipeWire)."""
|
|
||||||
try:
|
|
||||||
# Get default sink volume
|
|
||||||
result = subprocess.run(
|
|
||||||
["pactl", "get-sink-volume", "@DEFAULT_SINK@"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
# Parse volume from output like "Volume: front-left: 65536 / 100% / 0.00 dB"
|
|
||||||
for part in result.stdout.split("/"):
|
|
||||||
if "%" in part:
|
|
||||||
volume = int(part.strip().rstrip("%"))
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
volume = 100
|
|
||||||
else:
|
|
||||||
volume = 100
|
|
||||||
|
|
||||||
# Get mute status
|
|
||||||
result = subprocess.run(
|
|
||||||
["pactl", "get-sink-mute", "@DEFAULT_SINK@"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
muted = "yes" in result.stdout.lower() if result.returncode == 0 else False
|
|
||||||
|
|
||||||
return volume, muted
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get volume via pactl: {e}")
|
|
||||||
return 100, False
|
|
||||||
|
|
||||||
def _set_volume_pulseaudio(self, volume: int) -> bool:
|
|
||||||
"""Set volume using pactl."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{volume}%"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to set volume: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _toggle_mute_pulseaudio(self) -> bool:
|
|
||||||
"""Toggle mute using pactl, returns new mute state."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
_, muted = self._get_volume_pulseaudio()
|
|
||||||
return muted
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to toggle mute: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_status(self) -> MediaStatus:
|
|
||||||
"""Get current media playback status."""
|
|
||||||
status = MediaStatus()
|
|
||||||
|
|
||||||
# Get system volume
|
|
||||||
volume, muted = self._get_volume_pulseaudio()
|
|
||||||
status.volume = volume
|
|
||||||
status.muted = muted
|
|
||||||
|
|
||||||
# Get active player
|
|
||||||
player_name = self._get_active_player()
|
|
||||||
if player_name is None:
|
|
||||||
status.state = MediaState.IDLE
|
|
||||||
return status
|
|
||||||
|
|
||||||
# Get playback status
|
|
||||||
playback_status = self._get_property(player_name, "PlaybackStatus")
|
|
||||||
if playback_status == "Playing":
|
|
||||||
status.state = MediaState.PLAYING
|
|
||||||
elif playback_status == "Paused":
|
|
||||||
status.state = MediaState.PAUSED
|
|
||||||
elif playback_status == "Stopped":
|
|
||||||
status.state = MediaState.STOPPED
|
|
||||||
else:
|
|
||||||
status.state = MediaState.IDLE
|
|
||||||
|
|
||||||
# Get metadata
|
|
||||||
metadata = self._get_property(player_name, "Metadata")
|
|
||||||
if metadata:
|
|
||||||
status.title = str(metadata.get("xesam:title", "")) or None
|
|
||||||
|
|
||||||
artists = metadata.get("xesam:artist", [])
|
|
||||||
if artists:
|
|
||||||
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
|
|
||||||
|
|
||||||
status.album = str(metadata.get("xesam:album", "")) or None
|
|
||||||
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
|
|
||||||
|
|
||||||
# Duration in microseconds
|
|
||||||
length = metadata.get("mpris:length", 0)
|
|
||||||
if length:
|
|
||||||
status.duration = int(length) / 1_000_000
|
|
||||||
|
|
||||||
# Get position (in microseconds)
|
|
||||||
position = self._get_property(player_name, "Position")
|
|
||||||
if position is not None:
|
|
||||||
status.position = int(position) / 1_000_000
|
|
||||||
|
|
||||||
# Get source name
|
|
||||||
status.source = player_name.replace(self.MPRIS_PREFIX, "")
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
async def play(self) -> bool:
|
|
||||||
"""Resume playback."""
|
|
||||||
player_name = self._get_active_player()
|
|
||||||
if player_name is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
player = self._get_player_interface(player_name)
|
|
||||||
player.Play()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to play: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def pause(self) -> bool:
|
|
||||||
"""Pause playback."""
|
|
||||||
player_name = self._get_active_player()
|
|
||||||
if player_name is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
player = self._get_player_interface(player_name)
|
|
||||||
player.Pause()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to pause: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def stop(self) -> bool:
|
|
||||||
"""Stop playback."""
|
|
||||||
player_name = self._get_active_player()
|
|
||||||
if player_name is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
player = self._get_player_interface(player_name)
|
|
||||||
player.Stop()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to stop: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def next_track(self) -> bool:
|
|
||||||
"""Skip to next track."""
|
|
||||||
player_name = self._get_active_player()
|
|
||||||
if player_name is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
player = self._get_player_interface(player_name)
|
|
||||||
player.Next()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to skip next: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def previous_track(self) -> bool:
|
|
||||||
"""Go to previous track."""
|
|
||||||
player_name = self._get_active_player()
|
|
||||||
if player_name is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
player = self._get_player_interface(player_name)
|
|
||||||
player.Previous()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to skip previous: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
|
||||||
"""Set system volume."""
|
|
||||||
return self._set_volume_pulseaudio(volume)
|
|
||||||
|
|
||||||
async def toggle_mute(self) -> bool:
|
|
||||||
"""Toggle mute state."""
|
|
||||||
return self._toggle_mute_pulseaudio()
|
|
||||||
|
|
||||||
async def seek(self, position: float) -> bool:
|
|
||||||
"""Seek to position in seconds."""
|
|
||||||
player_name = self._get_active_player()
|
|
||||||
if player_name is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
player = self._get_player_interface(player_name)
|
|
||||||
# MPRIS expects position in microseconds
|
|
||||||
player.SetPosition(
|
|
||||||
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
|
|
||||||
int(position * 1_000_000),
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to seek: {e}")
|
|
||||||
return False
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
"""macOS media controller using AppleScript and system commands."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
import json
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ..models import MediaState, MediaStatus
|
|
||||||
from .media_controller import MediaController
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MacOSMediaController(MediaController):
|
|
||||||
"""Media controller for macOS using osascript and system commands."""
|
|
||||||
|
|
||||||
def _run_osascript(self, script: str) -> Optional[str]:
|
|
||||||
"""Run an AppleScript and return the output."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["osascript", "-e", script],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result.stdout.strip()
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"osascript error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_active_app(self) -> Optional[str]:
|
|
||||||
"""Get the currently active media application."""
|
|
||||||
# Check common media apps in order of preference
|
|
||||||
apps = ["Spotify", "Music", "TV", "VLC", "QuickTime Player"]
|
|
||||||
|
|
||||||
for app in apps:
|
|
||||||
script = f'''
|
|
||||||
tell application "System Events"
|
|
||||||
if exists (processes where name is "{app}") then
|
|
||||||
return "{app}"
|
|
||||||
end if
|
|
||||||
end tell
|
|
||||||
return ""
|
|
||||||
'''
|
|
||||||
result = self._run_osascript(script)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_spotify_info(self) -> dict:
|
|
||||||
"""Get playback info from Spotify."""
|
|
||||||
script = '''
|
|
||||||
tell application "Spotify"
|
|
||||||
if player state is playing then
|
|
||||||
set currentState to "playing"
|
|
||||||
else if player state is paused then
|
|
||||||
set currentState to "paused"
|
|
||||||
else
|
|
||||||
set currentState to "stopped"
|
|
||||||
end if
|
|
||||||
|
|
||||||
try
|
|
||||||
set trackName to name of current track
|
|
||||||
set artistName to artist of current track
|
|
||||||
set albumName to album of current track
|
|
||||||
set trackDuration to duration of current track
|
|
||||||
set trackPosition to player position
|
|
||||||
set artUrl to artwork url of current track
|
|
||||||
on error
|
|
||||||
set trackName to ""
|
|
||||||
set artistName to ""
|
|
||||||
set albumName to ""
|
|
||||||
set trackDuration to 0
|
|
||||||
set trackPosition to 0
|
|
||||||
set artUrl to ""
|
|
||||||
end try
|
|
||||||
|
|
||||||
return currentState & "|" & trackName & "|" & artistName & "|" & albumName & "|" & trackDuration & "|" & trackPosition & "|" & artUrl
|
|
||||||
end tell
|
|
||||||
'''
|
|
||||||
result = self._run_osascript(script)
|
|
||||||
if result:
|
|
||||||
parts = result.split("|")
|
|
||||||
if len(parts) >= 7:
|
|
||||||
return {
|
|
||||||
"state": parts[0],
|
|
||||||
"title": parts[1] or None,
|
|
||||||
"artist": parts[2] or None,
|
|
||||||
"album": parts[3] or None,
|
|
||||||
"duration": float(parts[4]) / 1000 if parts[4] else None, # ms to seconds
|
|
||||||
"position": float(parts[5]) if parts[5] else None,
|
|
||||||
"art_url": parts[6] or None,
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _get_music_info(self) -> dict:
|
|
||||||
"""Get playback info from Apple Music."""
|
|
||||||
script = '''
|
|
||||||
tell application "Music"
|
|
||||||
if player state is playing then
|
|
||||||
set currentState to "playing"
|
|
||||||
else if player state is paused then
|
|
||||||
set currentState to "paused"
|
|
||||||
else
|
|
||||||
set currentState to "stopped"
|
|
||||||
end if
|
|
||||||
|
|
||||||
try
|
|
||||||
set trackName to name of current track
|
|
||||||
set artistName to artist of current track
|
|
||||||
set albumName to album of current track
|
|
||||||
set trackDuration to duration of current track
|
|
||||||
set trackPosition to player position
|
|
||||||
on error
|
|
||||||
set trackName to ""
|
|
||||||
set artistName to ""
|
|
||||||
set albumName to ""
|
|
||||||
set trackDuration to 0
|
|
||||||
set trackPosition to 0
|
|
||||||
end try
|
|
||||||
|
|
||||||
return currentState & "|" & trackName & "|" & artistName & "|" & albumName & "|" & trackDuration & "|" & trackPosition
|
|
||||||
end tell
|
|
||||||
'''
|
|
||||||
result = self._run_osascript(script)
|
|
||||||
if result:
|
|
||||||
parts = result.split("|")
|
|
||||||
if len(parts) >= 6:
|
|
||||||
return {
|
|
||||||
"state": parts[0],
|
|
||||||
"title": parts[1] or None,
|
|
||||||
"artist": parts[2] or None,
|
|
||||||
"album": parts[3] or None,
|
|
||||||
"duration": float(parts[4]) if parts[4] else None,
|
|
||||||
"position": float(parts[5]) if parts[5] else None,
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _get_volume(self) -> tuple[int, bool]:
|
|
||||||
"""Get system volume and mute state."""
|
|
||||||
try:
|
|
||||||
# Get volume level
|
|
||||||
result = self._run_osascript("output volume of (get volume settings)")
|
|
||||||
volume = int(result) if result else 100
|
|
||||||
|
|
||||||
# Get mute state
|
|
||||||
result = self._run_osascript("output muted of (get volume settings)")
|
|
||||||
muted = result == "true"
|
|
||||||
|
|
||||||
return volume, muted
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get volume: {e}")
|
|
||||||
return 100, False
|
|
||||||
|
|
||||||
async def get_status(self) -> MediaStatus:
|
|
||||||
"""Get current media playback status."""
|
|
||||||
status = MediaStatus()
|
|
||||||
|
|
||||||
# Get system volume
|
|
||||||
volume, muted = self._get_volume()
|
|
||||||
status.volume = volume
|
|
||||||
status.muted = muted
|
|
||||||
|
|
||||||
# Try to get info from active media app
|
|
||||||
active_app = self._get_active_app()
|
|
||||||
if active_app is None:
|
|
||||||
status.state = MediaState.IDLE
|
|
||||||
return status
|
|
||||||
|
|
||||||
status.source = active_app
|
|
||||||
|
|
||||||
if active_app == "Spotify":
|
|
||||||
info = self._get_spotify_info()
|
|
||||||
elif active_app == "Music":
|
|
||||||
info = self._get_music_info()
|
|
||||||
else:
|
|
||||||
info = {}
|
|
||||||
|
|
||||||
if info:
|
|
||||||
state = info.get("state", "stopped")
|
|
||||||
if state == "playing":
|
|
||||||
status.state = MediaState.PLAYING
|
|
||||||
elif state == "paused":
|
|
||||||
status.state = MediaState.PAUSED
|
|
||||||
else:
|
|
||||||
status.state = MediaState.STOPPED
|
|
||||||
|
|
||||||
status.title = info.get("title")
|
|
||||||
status.artist = info.get("artist")
|
|
||||||
status.album = info.get("album")
|
|
||||||
status.duration = info.get("duration")
|
|
||||||
status.position = info.get("position")
|
|
||||||
status.album_art_url = info.get("art_url")
|
|
||||||
else:
|
|
||||||
status.state = MediaState.IDLE
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
async def play(self) -> bool:
|
|
||||||
"""Resume playback using media key simulation."""
|
|
||||||
# Use system media key
|
|
||||||
script = '''
|
|
||||||
tell application "System Events"
|
|
||||||
key code 16 using {command down, option down}
|
|
||||||
end tell
|
|
||||||
'''
|
|
||||||
# Fallback: try specific app
|
|
||||||
active_app = self._get_active_app()
|
|
||||||
if active_app == "Spotify":
|
|
||||||
self._run_osascript('tell application "Spotify" to play')
|
|
||||||
return True
|
|
||||||
elif active_app == "Music":
|
|
||||||
self._run_osascript('tell application "Music" to play')
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Use media key simulation
|
|
||||||
result = subprocess.run(
|
|
||||||
["osascript", "-e", 'tell application "System Events" to key code 49'],
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
async def pause(self) -> bool:
|
|
||||||
"""Pause playback."""
|
|
||||||
active_app = self._get_active_app()
|
|
||||||
if active_app == "Spotify":
|
|
||||||
self._run_osascript('tell application "Spotify" to pause')
|
|
||||||
return True
|
|
||||||
elif active_app == "Music":
|
|
||||||
self._run_osascript('tell application "Music" to pause')
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def stop(self) -> bool:
|
|
||||||
"""Stop playback."""
|
|
||||||
active_app = self._get_active_app()
|
|
||||||
if active_app == "Spotify":
|
|
||||||
self._run_osascript('tell application "Spotify" to pause')
|
|
||||||
return True
|
|
||||||
elif active_app == "Music":
|
|
||||||
self._run_osascript('tell application "Music" to stop')
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def next_track(self) -> bool:
|
|
||||||
"""Skip to next track."""
|
|
||||||
active_app = self._get_active_app()
|
|
||||||
if active_app == "Spotify":
|
|
||||||
self._run_osascript('tell application "Spotify" to next track')
|
|
||||||
return True
|
|
||||||
elif active_app == "Music":
|
|
||||||
self._run_osascript('tell application "Music" to next track')
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def previous_track(self) -> bool:
|
|
||||||
"""Go to previous track."""
|
|
||||||
active_app = self._get_active_app()
|
|
||||||
if active_app == "Spotify":
|
|
||||||
self._run_osascript('tell application "Spotify" to previous track')
|
|
||||||
return True
|
|
||||||
elif active_app == "Music":
|
|
||||||
self._run_osascript('tell application "Music" to previous track')
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
|
||||||
"""Set system volume."""
|
|
||||||
result = self._run_osascript(f"set volume output volume {volume}")
|
|
||||||
return result is not None or True # osascript returns empty on success
|
|
||||||
|
|
||||||
async def toggle_mute(self) -> bool:
|
|
||||||
"""Toggle mute state."""
|
|
||||||
_, current_mute = self._get_volume()
|
|
||||||
new_mute = not current_mute
|
|
||||||
self._run_osascript(f"set volume output muted {str(new_mute).lower()}")
|
|
||||||
return new_mute
|
|
||||||
|
|
||||||
async def seek(self, position: float) -> bool:
|
|
||||||
"""Seek to position in seconds."""
|
|
||||||
active_app = self._get_active_app()
|
|
||||||
if active_app == "Spotify":
|
|
||||||
self._run_osascript(
|
|
||||||
f'tell application "Spotify" to set player position to {position}'
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
elif active_app == "Music":
|
|
||||||
self._run_osascript(
|
|
||||||
f'tell application "Music" to set player position to {position}'
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"""Abstract base class for media controllers."""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
from ..models import MediaStatus
|
|
||||||
|
|
||||||
|
|
||||||
class MediaController(ABC):
|
|
||||||
"""Abstract base class for platform-specific media controllers."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_status(self) -> MediaStatus:
|
|
||||||
"""Get the current media playback status.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
MediaStatus with current playback info
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def play(self) -> bool:
|
|
||||||
"""Resume or start playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def pause(self) -> bool:
|
|
||||||
"""Pause playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def stop(self) -> bool:
|
|
||||||
"""Stop playback.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def next_track(self) -> bool:
|
|
||||||
"""Skip to the next track.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def previous_track(self) -> bool:
|
|
||||||
"""Go to the previous track.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
|
||||||
"""Set the system volume.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
volume: Volume level (0-100)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def toggle_mute(self) -> bool:
|
|
||||||
"""Toggle the mute state.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The new mute state (True = muted)
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def seek(self, position: float) -> bool:
|
|
||||||
"""Seek to a position in the current track.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
position: Position in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
"""WebSocket connection manager and status broadcaster."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Any, Callable, Coroutine
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
|
||||||
"""Manages WebSocket connections and broadcasts status updates."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Initialize the connection manager."""
|
|
||||||
self._active_connections: set[WebSocket] = set()
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
self._last_status: dict[str, Any] | None = None
|
|
||||||
self._broadcast_task: asyncio.Task | None = None
|
|
||||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
|
||||||
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
|
|
||||||
self._last_broadcast_time: float = 0.0
|
|
||||||
self._running: bool = False
|
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket) -> None:
|
|
||||||
"""Accept a new WebSocket connection."""
|
|
||||||
await websocket.accept()
|
|
||||||
async with self._lock:
|
|
||||||
self._active_connections.add(websocket)
|
|
||||||
logger.info(
|
|
||||||
"WebSocket client connected. Total: %d", len(self._active_connections)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send current status immediately upon connection
|
|
||||||
if self._last_status:
|
|
||||||
try:
|
|
||||||
await websocket.send_json({"type": "status", "data": self._last_status})
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Failed to send initial status: %s", e)
|
|
||||||
|
|
||||||
async def disconnect(self, websocket: WebSocket) -> None:
|
|
||||||
"""Remove a WebSocket connection."""
|
|
||||||
async with self._lock:
|
|
||||||
self._active_connections.discard(websocket)
|
|
||||||
logger.info(
|
|
||||||
"WebSocket client disconnected. Total: %d", len(self._active_connections)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
||||||
"""Broadcast a message to all connected clients."""
|
|
||||||
async with self._lock:
|
|
||||||
connections = list(self._active_connections)
|
|
||||||
|
|
||||||
if not connections:
|
|
||||||
return
|
|
||||||
|
|
||||||
disconnected = []
|
|
||||||
for websocket in connections:
|
|
||||||
try:
|
|
||||||
await websocket.send_json(message)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Failed to send to client: %s", e)
|
|
||||||
disconnected.append(websocket)
|
|
||||||
|
|
||||||
# Clean up disconnected clients
|
|
||||||
for ws in disconnected:
|
|
||||||
await self.disconnect(ws)
|
|
||||||
|
|
||||||
def status_changed(
|
|
||||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
|
||||||
) -> bool:
|
|
||||||
"""Detect if media status has meaningfully changed.
|
|
||||||
|
|
||||||
Position is NOT included for normal playback (let HA interpolate).
|
|
||||||
But seeks (large unexpected jumps) are detected.
|
|
||||||
"""
|
|
||||||
if old is None:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Fields to compare for changes (NO position - let HA interpolate)
|
|
||||||
significant_fields = [
|
|
||||||
"state",
|
|
||||||
"title",
|
|
||||||
"artist",
|
|
||||||
"album",
|
|
||||||
"volume",
|
|
||||||
"muted",
|
|
||||||
"duration",
|
|
||||||
"source",
|
|
||||||
"album_art_url",
|
|
||||||
]
|
|
||||||
|
|
||||||
for field in significant_fields:
|
|
||||||
if old.get(field) != new.get(field):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Detect seeks - large position jumps that aren't normal playback
|
|
||||||
old_pos = old.get("position") or 0
|
|
||||||
new_pos = new.get("position") or 0
|
|
||||||
pos_diff = new_pos - old_pos
|
|
||||||
|
|
||||||
# During playback, position should increase by ~0.5s (our poll interval)
|
|
||||||
# A seek is when position jumps backwards OR forward by more than expected
|
|
||||||
if new.get("state") == "playing":
|
|
||||||
# Backward seek or forward jump > 3s indicates seek
|
|
||||||
if pos_diff < -1.0 or pos_diff > 3.0:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# When paused, any significant position change is a seek
|
|
||||||
if abs(pos_diff) > 1.0:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def start_status_monitor(
|
|
||||||
self,
|
|
||||||
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
|
||||||
) -> None:
|
|
||||||
"""Start the background status monitoring loop."""
|
|
||||||
if self._running:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
self._broadcast_task = asyncio.create_task(
|
|
||||||
self._status_monitor_loop(get_status_func)
|
|
||||||
)
|
|
||||||
logger.info("WebSocket status monitor started")
|
|
||||||
|
|
||||||
async def stop_status_monitor(self) -> None:
|
|
||||||
"""Stop the background status monitoring loop."""
|
|
||||||
self._running = False
|
|
||||||
if self._broadcast_task:
|
|
||||||
self._broadcast_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._broadcast_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
logger.info("WebSocket status monitor stopped")
|
|
||||||
|
|
||||||
async def _status_monitor_loop(
|
|
||||||
self,
|
|
||||||
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
|
||||||
) -> None:
|
|
||||||
"""Background loop that polls for status changes and broadcasts."""
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
# Only poll if we have connected clients
|
|
||||||
async with self._lock:
|
|
||||||
has_clients = len(self._active_connections) > 0
|
|
||||||
|
|
||||||
if has_clients:
|
|
||||||
status = await get_status_func()
|
|
||||||
status_dict = status.model_dump()
|
|
||||||
|
|
||||||
# Only broadcast on actual state changes
|
|
||||||
# Let HA handle position interpolation during playback
|
|
||||||
if self.status_changed(self._last_status, status_dict):
|
|
||||||
self._last_status = status_dict
|
|
||||||
self._last_broadcast_time = time.time()
|
|
||||||
await self.broadcast(
|
|
||||||
{"type": "status_update", "data": status_dict}
|
|
||||||
)
|
|
||||||
logger.debug("Broadcast sent: status change")
|
|
||||||
else:
|
|
||||||
# Update cached status even without broadcast
|
|
||||||
self._last_status = status_dict
|
|
||||||
else:
|
|
||||||
# Still update cache for when clients connect
|
|
||||||
status = await get_status_func()
|
|
||||||
self._last_status = status.model_dump()
|
|
||||||
|
|
||||||
await asyncio.sleep(self._poll_interval)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error in status monitor: %s", e)
|
|
||||||
await asyncio.sleep(self._poll_interval)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def client_count(self) -> int:
|
|
||||||
"""Return the number of connected clients."""
|
|
||||||
return len(self._active_connections)
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
ws_manager = ConnectionManager()
|
|
||||||
@@ -1,596 +0,0 @@
|
|||||||
"""Windows media controller using WinRT APIs."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from typing import Optional, Any
|
|
||||||
|
|
||||||
from ..models import MediaState, MediaStatus
|
|
||||||
from .media_controller import MediaController
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Thread pool for WinRT operations (they don't play well with asyncio)
|
|
||||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
|
|
||||||
|
|
||||||
# Global storage for current album art (as bytes)
|
|
||||||
_current_album_art_bytes: bytes | None = None
|
|
||||||
|
|
||||||
# Global storage for position tracking
|
|
||||||
import time as _time
|
|
||||||
_position_cache = {
|
|
||||||
"track_id": "",
|
|
||||||
"base_position": 0.0,
|
|
||||||
"base_time": 0.0,
|
|
||||||
"is_playing": False,
|
|
||||||
"duration": 0.0,
|
|
||||||
}
|
|
||||||
# Flag to force position to 0 after track skip (until title changes)
|
|
||||||
_track_skip_pending = {
|
|
||||||
"active": False,
|
|
||||||
"old_title": "",
|
|
||||||
"skip_time": 0.0,
|
|
||||||
"grace_until": 0.0, # After title changes, ignore stale SMTC positions
|
|
||||||
"stale_pos": -999, # The stale SMTC position we're ignoring
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_album_art() -> bytes | None:
|
|
||||||
"""Get the current album art bytes."""
|
|
||||||
return _current_album_art_bytes
|
|
||||||
|
|
||||||
# Windows-specific imports
|
|
||||||
try:
|
|
||||||
from winsdk.windows.media.control import (
|
|
||||||
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
|
|
||||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
WINSDK_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
WINSDK_AVAILABLE = False
|
|
||||||
logger.warning("winsdk not available")
|
|
||||||
|
|
||||||
# Volume control imports
|
|
||||||
PYCAW_AVAILABLE = False
|
|
||||||
_volume_control = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from ctypes import cast, POINTER
|
|
||||||
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
|
|
||||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
|
||||||
|
|
||||||
def _init_volume_control():
|
|
||||||
"""Initialize volume control interface."""
|
|
||||||
global _volume_control
|
|
||||||
if _volume_control is not None:
|
|
||||||
return _volume_control
|
|
||||||
try:
|
|
||||||
devices = AudioUtilities.GetSpeakers()
|
|
||||||
interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
|
||||||
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
|
|
||||||
return _volume_control
|
|
||||||
except AttributeError:
|
|
||||||
# Try accessing the underlying device
|
|
||||||
try:
|
|
||||||
devices = AudioUtilities.GetSpeakers()
|
|
||||||
if hasattr(devices, '_dev'):
|
|
||||||
interface = devices._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
|
||||||
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
|
|
||||||
return _volume_control
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Volume control init failed: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Volume control init error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
PYCAW_AVAILABLE = True
|
|
||||||
except ImportError as e:
|
|
||||||
logger.warning(f"pycaw not available: {e}")
|
|
||||||
|
|
||||||
def _init_volume_control():
|
|
||||||
return None
|
|
||||||
|
|
||||||
WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_get_media_status() -> dict[str, Any]:
|
|
||||||
"""Synchronously get media status (runs in thread pool)."""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"state": "idle",
|
|
||||||
"title": None,
|
|
||||||
"artist": None,
|
|
||||||
"album": None,
|
|
||||||
"duration": None,
|
|
||||||
"position": None,
|
|
||||||
"source": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create a new event loop for this thread
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get media session manager
|
|
||||||
manager = loop.run_until_complete(MediaManager.request_async())
|
|
||||||
if manager is None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
session = _find_best_session(manager, loop)
|
|
||||||
if session is None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Get playback status
|
|
||||||
playback_info = session.get_playback_info()
|
|
||||||
if playback_info:
|
|
||||||
status = playback_info.playback_status
|
|
||||||
if status == PlaybackStatus.PLAYING:
|
|
||||||
result["state"] = "playing"
|
|
||||||
elif status == PlaybackStatus.PAUSED:
|
|
||||||
result["state"] = "paused"
|
|
||||||
elif status == PlaybackStatus.STOPPED:
|
|
||||||
result["state"] = "stopped"
|
|
||||||
|
|
||||||
# Get media properties FIRST (needed for track ID)
|
|
||||||
media_props = loop.run_until_complete(
|
|
||||||
session.try_get_media_properties_async()
|
|
||||||
)
|
|
||||||
if media_props:
|
|
||||||
result["title"] = media_props.title or None
|
|
||||||
result["artist"] = media_props.artist or None
|
|
||||||
result["album"] = media_props.album_title or None
|
|
||||||
|
|
||||||
# Get timeline
|
|
||||||
timeline = session.get_timeline_properties()
|
|
||||||
if timeline:
|
|
||||||
try:
|
|
||||||
# end_time and position are datetime.timedelta objects
|
|
||||||
end_time = timeline.end_time
|
|
||||||
position = timeline.position
|
|
||||||
|
|
||||||
# Get duration
|
|
||||||
if hasattr(end_time, 'total_seconds'):
|
|
||||||
duration = end_time.total_seconds()
|
|
||||||
# Sanity check: duration should be positive and reasonable (< 24 hours)
|
|
||||||
if 0 < duration < 86400:
|
|
||||||
result["duration"] = duration
|
|
||||||
|
|
||||||
# Get position from SMTC and interpolate for smooth updates
|
|
||||||
if hasattr(position, 'total_seconds'):
|
|
||||||
smtc_pos = position.total_seconds()
|
|
||||||
current_time = _time.time()
|
|
||||||
is_playing = result["state"] == "playing"
|
|
||||||
current_title = result.get('title', '')
|
|
||||||
|
|
||||||
# Check if track skip is pending and title changed
|
|
||||||
skip_just_completed = False
|
|
||||||
if _track_skip_pending["active"]:
|
|
||||||
if current_title and current_title != _track_skip_pending["old_title"]:
|
|
||||||
# Title changed - clear the skip flag and start grace period
|
|
||||||
_track_skip_pending["active"] = False
|
|
||||||
_track_skip_pending["old_title"] = ""
|
|
||||||
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
|
|
||||||
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
|
|
||||||
skip_just_completed = True
|
|
||||||
# Reset position cache for new track
|
|
||||||
new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
|
|
||||||
_position_cache["track_id"] = new_track_id
|
|
||||||
_position_cache["base_position"] = 0.0
|
|
||||||
_position_cache["base_time"] = current_time
|
|
||||||
_position_cache["last_smtc_pos"] = -999 # Force fresh start
|
|
||||||
_position_cache["is_playing"] = is_playing
|
|
||||||
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
|
|
||||||
elif current_time - _track_skip_pending["skip_time"] > 5.0:
|
|
||||||
# Timeout after 5 seconds
|
|
||||||
_track_skip_pending["active"] = False
|
|
||||||
logger.debug("Track skip timeout")
|
|
||||||
|
|
||||||
# Check if we're in grace period (after skip, ignore high SMTC positions)
|
|
||||||
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
|
|
||||||
|
|
||||||
# If track skip is pending or just completed, use cached/reset position
|
|
||||||
if _track_skip_pending["active"]:
|
|
||||||
pos = 0.0
|
|
||||||
_position_cache["base_position"] = 0.0
|
|
||||||
_position_cache["base_time"] = current_time
|
|
||||||
_position_cache["is_playing"] = is_playing
|
|
||||||
elif skip_just_completed:
|
|
||||||
# Just completed skip - interpolate from 0
|
|
||||||
if is_playing:
|
|
||||||
elapsed = current_time - _position_cache["base_time"]
|
|
||||||
pos = elapsed
|
|
||||||
else:
|
|
||||||
pos = 0.0
|
|
||||||
elif in_grace_period:
|
|
||||||
# Grace period after track skip
|
|
||||||
# SMTC position is stale (from old track) and won't update until seek/pause
|
|
||||||
# We interpolate from 0 and only trust SMTC when it changes or reports low value
|
|
||||||
|
|
||||||
# Calculate interpolated position from start of new track
|
|
||||||
if is_playing:
|
|
||||||
elapsed = current_time - _position_cache.get("base_time", current_time)
|
|
||||||
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
|
|
||||||
else:
|
|
||||||
interpolated_pos = _position_cache.get("base_position", 0.0)
|
|
||||||
|
|
||||||
# Get the stale position we've been tracking
|
|
||||||
stale_pos = _track_skip_pending.get("stale_pos", -999)
|
|
||||||
|
|
||||||
# Detect if SMTC position changed significantly from the stale value (user seeked)
|
|
||||||
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
|
|
||||||
|
|
||||||
# Trust SMTC if:
|
|
||||||
# 1. It reports a low position (indicating new track started)
|
|
||||||
# 2. It changed from the stale value (user seeked)
|
|
||||||
if smtc_pos < 10.0 or smtc_changed:
|
|
||||||
# SMTC is now trustworthy
|
|
||||||
_position_cache["base_position"] = smtc_pos
|
|
||||||
_position_cache["base_time"] = current_time
|
|
||||||
_position_cache["last_smtc_pos"] = smtc_pos
|
|
||||||
_position_cache["is_playing"] = is_playing
|
|
||||||
pos = smtc_pos
|
|
||||||
_track_skip_pending["grace_until"] = 0
|
|
||||||
_track_skip_pending["stale_pos"] = -999
|
|
||||||
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
|
|
||||||
else:
|
|
||||||
# SMTC is stale - keep interpolating
|
|
||||||
pos = interpolated_pos
|
|
||||||
# Record the stale position for change detection
|
|
||||||
if stale_pos < 0:
|
|
||||||
_track_skip_pending["stale_pos"] = smtc_pos
|
|
||||||
# Keep grace period active indefinitely while SMTC is stale
|
|
||||||
_track_skip_pending["grace_until"] = current_time + 300.0
|
|
||||||
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
|
|
||||||
else:
|
|
||||||
# Normal position tracking
|
|
||||||
# Create track ID from title + artist + duration
|
|
||||||
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
|
|
||||||
|
|
||||||
# Detect if SMTC position changed (new track, seek, or state change)
|
|
||||||
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
|
|
||||||
track_changed = track_id != _position_cache.get("track_id", "")
|
|
||||||
|
|
||||||
if smtc_pos_changed or track_changed:
|
|
||||||
# SMTC updated - store new baseline
|
|
||||||
_position_cache["track_id"] = track_id
|
|
||||||
_position_cache["last_smtc_pos"] = smtc_pos
|
|
||||||
_position_cache["base_position"] = smtc_pos
|
|
||||||
_position_cache["base_time"] = current_time
|
|
||||||
_position_cache["is_playing"] = is_playing
|
|
||||||
pos = smtc_pos
|
|
||||||
elif is_playing:
|
|
||||||
# Interpolate position based on elapsed time
|
|
||||||
elapsed = current_time - _position_cache.get("base_time", current_time)
|
|
||||||
pos = _position_cache.get("base_position", smtc_pos) + elapsed
|
|
||||||
else:
|
|
||||||
# Paused - use base position
|
|
||||||
pos = _position_cache.get("base_position", smtc_pos)
|
|
||||||
|
|
||||||
# Update playing state
|
|
||||||
if _position_cache.get("is_playing") != is_playing:
|
|
||||||
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
|
|
||||||
_position_cache["base_time"] = current_time
|
|
||||||
_position_cache["is_playing"] = is_playing
|
|
||||||
|
|
||||||
# Sanity check: position should be non-negative and <= duration
|
|
||||||
if pos >= 0:
|
|
||||||
if result["duration"] and pos <= result["duration"]:
|
|
||||||
result["position"] = pos
|
|
||||||
elif result["duration"] and pos > result["duration"]:
|
|
||||||
result["position"] = result["duration"]
|
|
||||||
elif not result["duration"]:
|
|
||||||
result["position"] = pos
|
|
||||||
|
|
||||||
logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Timeline parse error: {e}")
|
|
||||||
|
|
||||||
# Try to get album art (requires media_props)
|
|
||||||
if media_props:
|
|
||||||
try:
|
|
||||||
thumbnail = media_props.thumbnail
|
|
||||||
if thumbnail:
|
|
||||||
stream = loop.run_until_complete(thumbnail.open_read_async())
|
|
||||||
if stream:
|
|
||||||
size = stream.size
|
|
||||||
if size > 0 and size < 10 * 1024 * 1024: # Max 10MB
|
|
||||||
from winsdk.windows.storage.streams import DataReader
|
|
||||||
reader = DataReader(stream)
|
|
||||||
loop.run_until_complete(reader.load_async(size))
|
|
||||||
buffer = bytearray(size)
|
|
||||||
reader.read_bytes(buffer)
|
|
||||||
reader.close()
|
|
||||||
stream.close()
|
|
||||||
|
|
||||||
global _current_album_art_bytes
|
|
||||||
_current_album_art_bytes = bytes(buffer)
|
|
||||||
result["album_art_url"] = "/api/media/artwork"
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Failed to get album art: {e}")
|
|
||||||
|
|
||||||
result["source"] = session.source_app_user_model_id
|
|
||||||
|
|
||||||
finally:
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting media status: {e}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _find_best_session(manager, loop):
|
|
||||||
"""Find the best media session to control."""
|
|
||||||
# First try the current session
|
|
||||||
session = manager.get_current_session()
|
|
||||||
|
|
||||||
# Log all available sessions for debugging
|
|
||||||
sessions = manager.get_sessions()
|
|
||||||
if sessions:
|
|
||||||
logger.debug(f"Total sessions available: {sessions.size}")
|
|
||||||
for i in range(sessions.size):
|
|
||||||
s = sessions.get_at(i)
|
|
||||||
if s:
|
|
||||||
playback_info = s.get_playback_info()
|
|
||||||
status_name = "unknown"
|
|
||||||
if playback_info:
|
|
||||||
status_name = str(playback_info.playback_status)
|
|
||||||
logger.debug(f" Session {i}: {s.source_app_user_model_id} - status: {status_name}")
|
|
||||||
|
|
||||||
# If no current session, try to find any active session
|
|
||||||
if session is None:
|
|
||||||
if sessions and sessions.size > 0:
|
|
||||||
# Find a playing session, or use the first one
|
|
||||||
for i in range(sessions.size):
|
|
||||||
s = sessions.get_at(i)
|
|
||||||
if s:
|
|
||||||
playback_info = s.get_playback_info()
|
|
||||||
if playback_info and playback_info.playback_status == PlaybackStatus.PLAYING:
|
|
||||||
session = s
|
|
||||||
break
|
|
||||||
# If no playing session found, use the first available one
|
|
||||||
if session is None and sessions.size > 0:
|
|
||||||
session = sessions.get_at(0)
|
|
||||||
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_media_command(command: str) -> bool:
|
|
||||||
"""Synchronously execute a media command (runs in thread pool)."""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
|
||||||
manager = loop.run_until_complete(MediaManager.request_async())
|
|
||||||
if manager is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
session = _find_best_session(manager, loop)
|
|
||||||
if session is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if command == "play":
|
|
||||||
return loop.run_until_complete(session.try_play_async())
|
|
||||||
elif command == "pause":
|
|
||||||
return loop.run_until_complete(session.try_pause_async())
|
|
||||||
elif command == "stop":
|
|
||||||
return loop.run_until_complete(session.try_stop_async())
|
|
||||||
elif command == "next":
|
|
||||||
return loop.run_until_complete(session.try_skip_next_async())
|
|
||||||
elif command == "previous":
|
|
||||||
return loop.run_until_complete(session.try_skip_previous_async())
|
|
||||||
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error executing media command {command}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_seek(position: float) -> bool:
|
|
||||||
"""Synchronously seek to position."""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
|
||||||
manager = loop.run_until_complete(MediaManager.request_async())
|
|
||||||
if manager is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
session = _find_best_session(manager, loop)
|
|
||||||
if session is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
position_ticks = int(position * 10_000_000)
|
|
||||||
return loop.run_until_complete(
|
|
||||||
session.try_change_playback_position_async(position_ticks)
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error seeking: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class WindowsMediaController(MediaController):
|
|
||||||
"""Media controller for Windows using WinRT and pycaw."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
if not WINDOWS_AVAILABLE:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Windows media control requires winsdk, pycaw, and comtypes packages"
|
|
||||||
)
|
|
||||||
self._volume_interface = None
|
|
||||||
self._volume_init_attempted = False
|
|
||||||
|
|
||||||
def _get_volume_interface(self):
|
|
||||||
"""Get the audio endpoint volume interface."""
|
|
||||||
if not self._volume_init_attempted:
|
|
||||||
self._volume_init_attempted = True
|
|
||||||
self._volume_interface = _init_volume_control()
|
|
||||||
if self._volume_interface:
|
|
||||||
logger.info("Volume control initialized successfully")
|
|
||||||
else:
|
|
||||||
logger.warning("Volume control not available")
|
|
||||||
return self._volume_interface
|
|
||||||
|
|
||||||
async def get_status(self) -> MediaStatus:
|
|
||||||
"""Get current media playback status."""
|
|
||||||
status = MediaStatus()
|
|
||||||
|
|
||||||
# Get volume info (synchronous, fast)
|
|
||||||
volume_if = self._get_volume_interface()
|
|
||||||
if volume_if:
|
|
||||||
try:
|
|
||||||
volume_scalar = volume_if.GetMasterVolumeLevelScalar()
|
|
||||||
status.volume = int(volume_scalar * 100)
|
|
||||||
status.muted = bool(volume_if.GetMute())
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Failed to get volume: {e}")
|
|
||||||
|
|
||||||
# Get media info in thread pool (avoids asyncio/WinRT issues)
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
media_info = await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(_executor, _sync_get_media_status),
|
|
||||||
timeout=5.0
|
|
||||||
)
|
|
||||||
|
|
||||||
state_map = {
|
|
||||||
"playing": MediaState.PLAYING,
|
|
||||||
"paused": MediaState.PAUSED,
|
|
||||||
"stopped": MediaState.STOPPED,
|
|
||||||
"idle": MediaState.IDLE,
|
|
||||||
}
|
|
||||||
status.state = state_map.get(media_info.get("state", "idle"), MediaState.IDLE)
|
|
||||||
status.title = media_info.get("title")
|
|
||||||
status.artist = media_info.get("artist")
|
|
||||||
status.album = media_info.get("album")
|
|
||||||
status.album_art_url = media_info.get("album_art_url")
|
|
||||||
status.duration = media_info.get("duration")
|
|
||||||
status.position = media_info.get("position")
|
|
||||||
status.source = media_info.get("source")
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Media status request timed out")
|
|
||||||
status.state = MediaState.IDLE
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting media status: {e}")
|
|
||||||
status.state = MediaState.IDLE
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
async def _run_command(self, command: str) -> bool:
|
|
||||||
"""Run a media command in the thread pool."""
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(_executor, _sync_media_command, command),
|
|
||||||
timeout=5.0
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(f"Media command {command} timed out")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error running media command {command}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def play(self) -> bool:
|
|
||||||
"""Resume playback."""
|
|
||||||
return await self._run_command("play")
|
|
||||||
|
|
||||||
async def pause(self) -> bool:
|
|
||||||
"""Pause playback."""
|
|
||||||
return await self._run_command("pause")
|
|
||||||
|
|
||||||
async def stop(self) -> bool:
|
|
||||||
"""Stop playback."""
|
|
||||||
return await self._run_command("stop")
|
|
||||||
|
|
||||||
async def next_track(self) -> bool:
|
|
||||||
"""Skip to next track."""
|
|
||||||
# Get current title before skipping
|
|
||||||
try:
|
|
||||||
status = await self.get_status()
|
|
||||||
old_title = status.title or ""
|
|
||||||
except Exception:
|
|
||||||
old_title = ""
|
|
||||||
|
|
||||||
result = await self._run_command("next")
|
|
||||||
if result:
|
|
||||||
# Set flag to force position to 0 until title changes
|
|
||||||
_track_skip_pending["active"] = True
|
|
||||||
_track_skip_pending["old_title"] = old_title
|
|
||||||
_track_skip_pending["skip_time"] = _time.time()
|
|
||||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def previous_track(self) -> bool:
|
|
||||||
"""Go to previous track."""
|
|
||||||
# Get current title before skipping
|
|
||||||
try:
|
|
||||||
status = await self.get_status()
|
|
||||||
old_title = status.title or ""
|
|
||||||
except Exception:
|
|
||||||
old_title = ""
|
|
||||||
|
|
||||||
result = await self._run_command("previous")
|
|
||||||
if result:
|
|
||||||
# Set flag to force position to 0 until title changes
|
|
||||||
_track_skip_pending["active"] = True
|
|
||||||
_track_skip_pending["old_title"] = old_title
|
|
||||||
_track_skip_pending["skip_time"] = _time.time()
|
|
||||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
|
||||||
"""Set system volume."""
|
|
||||||
volume_if = self._get_volume_interface()
|
|
||||||
if volume_if is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
volume_if.SetMasterVolumeLevelScalar(volume / 100.0, None)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to set volume: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def toggle_mute(self) -> bool:
|
|
||||||
"""Toggle mute state."""
|
|
||||||
volume_if = self._get_volume_interface()
|
|
||||||
if volume_if is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
current_mute = bool(volume_if.GetMute())
|
|
||||||
volume_if.SetMute(not current_mute, None)
|
|
||||||
return not current_mute
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to toggle mute: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def seek(self, position: float) -> bool:
|
|
||||||
"""Seek to position in seconds."""
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(_executor, _sync_seek, position),
|
|
||||||
timeout=5.0
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("Seek command timed out")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to seek: {e}")
|
|
||||||
return False
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Core dependencies
|
|
||||||
fastapi>=0.109.0
|
|
||||||
uvicorn[standard]>=0.27.0
|
|
||||||
pydantic>=2.0
|
|
||||||
pydantic-settings>=2.0
|
|
||||||
pyyaml>=6.0
|
|
||||||
|
|
||||||
# Windows media control (install on Windows only)
|
|
||||||
# pip install winsdk pywin32 pycaw comtypes
|
|
||||||
winsdk>=1.0.0b10; sys_platform == "win32"
|
|
||||||
pywin32>=306; sys_platform == "win32"
|
|
||||||
comtypes>=1.2.0; sys_platform == "win32"
|
|
||||||
pycaw>=20230407; sys_platform == "win32"
|
|
||||||
|
|
||||||
# Linux media control (install on Linux only)
|
|
||||||
# pip install dbus-python PyGObject
|
|
||||||
# Note: dbus-python requires system dependencies:
|
|
||||||
# sudo apt-get install libdbus-1-dev libglib2.0-dev python3-gi
|
|
||||||
# dbus-python>=1.3.2; sys_platform == "linux"
|
|
||||||
# PyGObject>=3.46.0; sys_platform == "linux"
|
|
||||||
|
|
||||||
# macOS media control
|
|
||||||
# No additional dependencies needed - uses osascript (AppleScript)
|
|
||||||
|
|
||||||
# Android media control (via Termux)
|
|
||||||
# Requires Termux and Termux:API apps from F-Droid
|
|
||||||
# In Termux: pkg install python termux-api
|
|
||||||
# No additional pip packages needed
|
|
||||||
Reference in New Issue
Block a user