Compare commits
15 Commits
v1.0.0
...
d5ec5c611f
| Author | SHA1 | Date | |
|---|---|---|---|
| d5ec5c611f | |||
| 29e0618b9f | |||
| 4f8f59dc89 | |||
| 40c2c11c85 | |||
| 0470a17a0c | |||
| 4635caca98 | |||
| 957a177b72 | |||
| 8077181dce | |||
| 9bbb8e1bd7 | |||
| a0af855846 | |||
| d7c5994e56 | |||
| 71a0a6e6d1 | |||
| 5342cffac7 | |||
| a0d138bb93 | |||
| 1a1cfbaafb |
86
CLAUDE.md
86
CLAUDE.md
@@ -26,6 +26,55 @@ To remove the scheduled task:
|
|||||||
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
|
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Server Restart After Code Changes
|
||||||
|
|
||||||
|
**CRITICAL:** When making changes to backend code (Python files, API routes, service logic), the media server MUST be restarted for changes to take effect.
|
||||||
|
|
||||||
|
**When to restart:**
|
||||||
|
|
||||||
|
- Changes to any Python files (`*.py`) in the media_server directory
|
||||||
|
- Changes to API endpoints, routes, or request/response models
|
||||||
|
- Changes to service logic, callbacks, or script execution
|
||||||
|
- Changes to configuration handling or startup logic
|
||||||
|
|
||||||
|
**When restart is NOT needed:**
|
||||||
|
|
||||||
|
- Static file changes (`*.html`, `*.css`, `*.js`, `*.json`) - browser refresh is enough
|
||||||
|
- README or documentation updates
|
||||||
|
- Changes to install/service scripts (only affects new installations)
|
||||||
|
|
||||||
|
**How to restart during development:**
|
||||||
|
|
||||||
|
1. Find the running server process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
netstat -ano | findstr :8765
|
||||||
|
|
||||||
|
# Linux/macOS
|
||||||
|
lsof -i :8765
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Stop the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
taskkill //F //PID <process_id>
|
||||||
|
|
||||||
|
# Linux/macOS
|
||||||
|
kill <process_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the server again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m media_server.main
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best Practice:** Always restart the server immediately after committing backend changes to verify they work correctly before pushing.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copy `config.example.yaml` to `config.yaml` and customize.
|
Copy `config.example.yaml` to `config.yaml` and customize.
|
||||||
@@ -34,6 +83,43 @@ The API token is generated on first run and displayed in the console output.
|
|||||||
|
|
||||||
Default port: `8765`
|
Default port: `8765`
|
||||||
|
|
||||||
|
## Internationalization (i18n)
|
||||||
|
|
||||||
|
The Web UI supports multiple languages with translations stored in separate JSON files.
|
||||||
|
|
||||||
|
### Locale Files
|
||||||
|
|
||||||
|
Translation files are located in:
|
||||||
|
- `media_server/static/locales/en.json` - English (default)
|
||||||
|
- `media_server/static/locales/ru.json` - Russian
|
||||||
|
|
||||||
|
### Maintaining Translations
|
||||||
|
|
||||||
|
**IMPORTANT:** When adding or modifying user-facing text in the Web UI:
|
||||||
|
|
||||||
|
1. **Update all locale files** - Add or update the translation key in **both** `en.json` and `ru.json`
|
||||||
|
2. **Use consistent keys** - Follow the existing key naming pattern (e.g., `section.element`, `scripts.button.save`)
|
||||||
|
3. **Test both locales** - Verify translations appear correctly by switching between EN/RU
|
||||||
|
|
||||||
|
### Adding New Text
|
||||||
|
|
||||||
|
When adding new UI elements:
|
||||||
|
|
||||||
|
1. Add the English text to `static/locales/en.json`
|
||||||
|
2. Add the Russian translation to `static/locales/ru.json`
|
||||||
|
3. In HTML: use `data-i18n="key.name"` for text content
|
||||||
|
4. In HTML: use `data-i18n-placeholder="key.name"` for input placeholders
|
||||||
|
5. In HTML: use `data-i18n-title="key.name"` for title attributes
|
||||||
|
6. In JavaScript: use `t('key.name')` or `t('key.name', {param: value})` for dynamic text
|
||||||
|
|
||||||
|
### Adding New Locales
|
||||||
|
|
||||||
|
To add support for a new language:
|
||||||
|
|
||||||
|
1. Create `media_server/static/locales/{lang_code}.json` (copy from `en.json`)
|
||||||
|
2. Translate all strings to the new language
|
||||||
|
3. Add the language code to `supportedLocales` array in `index.html`
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
Version is tracked in two files that must be kept in sync:
|
Version is tracked in two files that must be kept in sync:
|
||||||
|
|||||||
223
README.md
223
README.md
@@ -4,14 +4,91 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- **Built-in Web UI** for real-time media control and monitoring
|
||||||
- Control any media player via system-wide media transport controls
|
- Control any media player via system-wide media transport controls
|
||||||
- Play/Pause/Stop/Next/Previous track
|
- Play/Pause/Stop/Next/Previous track
|
||||||
- Volume control and mute
|
- Volume control and mute
|
||||||
- Seek within tracks
|
- Seek within tracks
|
||||||
- Get current track info (title, artist, album, artwork)
|
- Get current track info (title, artist, album, artwork)
|
||||||
|
- WebSocket support for real-time updates
|
||||||
- Token-based authentication
|
- Token-based authentication
|
||||||
- Cross-platform support
|
- Cross-platform support
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
The media server includes a built-in web interface for controlling and monitoring media playback.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Real-time status updates** via WebSocket connection
|
||||||
|
- **Album artwork display** with automatic updates
|
||||||
|
- **Playback controls** - Play, pause, next, previous
|
||||||
|
- **Volume control** with mute toggle
|
||||||
|
- **Seekable progress bar** - Click to jump to any position
|
||||||
|
- **Connection status indicator** - Know when you're connected
|
||||||
|
- **Token authentication** - Saved in browser localStorage
|
||||||
|
- **Responsive design** - Works on desktop and mobile
|
||||||
|
- **Dark theme** - Easy on the eyes
|
||||||
|
- **Multi-language support** - English and Russian locales with automatic detection
|
||||||
|
|
||||||
|
### Accessing the Web UI
|
||||||
|
|
||||||
|
1. Start the media server:
|
||||||
|
```bash
|
||||||
|
python -m media_server.main
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Open your browser and navigate to:
|
||||||
|
```
|
||||||
|
http://localhost:8765/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Enter your API token when prompted (get it with `media-server --show-token`)
|
||||||
|
|
||||||
|
4. Start playing media in any supported player and watch the UI update in real-time!
|
||||||
|
|
||||||
|
### Remote Access
|
||||||
|
|
||||||
|
To access the Web UI from other devices on your network:
|
||||||
|
|
||||||
|
1. Find your computer's IP address (e.g., `192.168.1.100`)
|
||||||
|
2. Navigate to `http://192.168.1.100:8765/` from any device on the same network
|
||||||
|
3. Enter your API token
|
||||||
|
|
||||||
|
**Security Note:** For remote access over the internet, use a reverse proxy with HTTPS (nginx, Caddy) to encrypt traffic.
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
The Web UI supports multiple languages with automatic browser locale detection:
|
||||||
|
|
||||||
|
**Available Languages:**
|
||||||
|
|
||||||
|
- **English (en)** - Default
|
||||||
|
- **Русский (ru)** - Russian
|
||||||
|
|
||||||
|
The interface automatically detects your browser language on first visit. You can manually switch languages using the dropdown in the top-right corner of the Web UI.
|
||||||
|
|
||||||
|
**Contributing New Locales:**
|
||||||
|
|
||||||
|
We welcome translations for additional languages! To contribute a new locale:
|
||||||
|
|
||||||
|
1. Copy `media_server/static/locales/en.json` to a new file named with your language code (e.g., `de.json` for German)
|
||||||
|
2. Translate all strings to your language, keeping the same JSON structure
|
||||||
|
3. Add your language to the `supportedLocales` object in `media_server/static/index.html`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const supportedLocales = {
|
||||||
|
'en': 'English',
|
||||||
|
'ru': 'Русский',
|
||||||
|
'de': 'Deutsch' // Add your language here
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Test the translation by switching to your language in the Web UI
|
||||||
|
5. Submit a pull request with your changes
|
||||||
|
|
||||||
|
See [CLAUDE.md](CLAUDE.md#internationalization-i18n) for detailed translation guidelines.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
@@ -71,13 +148,17 @@ Requires Termux and Termux:API apps from F-Droid.
|
|||||||
python -m media_server.main
|
python -m media_server.main
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Test the connection:
|
4. **Open the Web UI** (recommended):
|
||||||
```bash
|
- Navigate to `http://localhost:8765/` in your browser
|
||||||
curl http://localhost:8765/api/health
|
- Enter your API token from step 2
|
||||||
```
|
- Start playing media and control it from the web interface!
|
||||||
|
|
||||||
5. Test with authentication:
|
5. Or test via API:
|
||||||
```bash
|
```bash
|
||||||
|
# Health check (no auth required)
|
||||||
|
curl http://localhost:8765/api/health
|
||||||
|
|
||||||
|
# Get media status
|
||||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
|
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,11 +173,46 @@ Configuration file locations:
|
|||||||
```yaml
|
```yaml
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8765
|
port: 8765
|
||||||
api_token: your-secret-token-here
|
|
||||||
|
# API Tokens - Multiple tokens with labels for client identification
|
||||||
|
api_tokens:
|
||||||
|
home_assistant: "your-home-assistant-token-here"
|
||||||
|
mobile: "your-mobile-app-token-here"
|
||||||
|
web_ui: "your-web-ui-token-here"
|
||||||
|
|
||||||
poll_interval: 1.0
|
poll_interval: 1.0
|
||||||
log_level: INFO
|
log_level: INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
The media server supports multiple API tokens with friendly labels. This allows you to:
|
||||||
|
- Issue different tokens for different clients (Home Assistant, mobile apps, web UI, etc.)
|
||||||
|
- Identify which client is making requests in the server logs
|
||||||
|
- Revoke individual tokens without affecting other clients
|
||||||
|
|
||||||
|
**Token labels** appear in all server logs, making it easy to track and debug client connections:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-02-06 03:36:20,806 - media_server.services.websocket_manager - [home_assistant] - INFO - WebSocket client connected
|
||||||
|
2026-02-06 03:28:24,258 - media_server.routes.scripts - [mobile] - INFO - Executing script: lock_screen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Viewing your tokens:**
|
||||||
|
```bash
|
||||||
|
python -m media_server.main --show-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
Config directory: C:\Users\...\AppData\Roaming\media-server
|
||||||
|
|
||||||
|
API Tokens:
|
||||||
|
home_assistant B04zhGDjnxH6LIwxL3VOT0F4qORwaipD7LoDyeAG4EU
|
||||||
|
mobile xyz123...
|
||||||
|
web_ui abc456...
|
||||||
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`):
|
All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`):
|
||||||
@@ -104,10 +220,11 @@ All settings can be overridden with environment variables (prefix: `MEDIA_SERVER
|
|||||||
```bash
|
```bash
|
||||||
export MEDIA_SERVER_HOST=0.0.0.0
|
export MEDIA_SERVER_HOST=0.0.0.0
|
||||||
export MEDIA_SERVER_PORT=8765
|
export MEDIA_SERVER_PORT=8765
|
||||||
export MEDIA_SERVER_API_TOKEN=your-token
|
|
||||||
export MEDIA_SERVER_LOG_LEVEL=DEBUG
|
export MEDIA_SERVER_LOG_LEVEL=DEBUG
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** For multi-token configuration, use the config.yaml file. Environment variables only support single-token mode.
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
### Health Check
|
### Health Check
|
||||||
@@ -164,6 +281,9 @@ All control endpoints require authentication and return `{"success": true}` on s
|
|||||||
| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
|
| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
|
||||||
| `/api/media/mute` | POST | - | Toggle mute |
|
| `/api/media/mute` | POST | - | Toggle mute |
|
||||||
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) |
|
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) |
|
||||||
|
| `/api/media/turn_on` | POST | - | Execute on_turn_on callback |
|
||||||
|
| `/api/media/turn_off` | POST | - | Execute on_turn_off callback |
|
||||||
|
| `/api/media/toggle` | POST | - | Execute on_toggle callback |
|
||||||
|
|
||||||
### Script Execution
|
### Script Execution
|
||||||
|
|
||||||
@@ -263,6 +383,95 @@ Script configuration options:
|
|||||||
| `working_dir` | No | Working directory for the command |
|
| `working_dir` | No | Working directory for the command |
|
||||||
| `shell` | No | Run in shell (default: true) |
|
| `shell` | No | Run in shell (default: true) |
|
||||||
|
|
||||||
|
### Configuring Callbacks
|
||||||
|
|
||||||
|
Callbacks are optional commands executed after media actions. Add them in your `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
callbacks:
|
||||||
|
# Media control callbacks (run after successful action)
|
||||||
|
on_play:
|
||||||
|
command: "echo Play triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_pause:
|
||||||
|
command: "echo Pause triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_stop:
|
||||||
|
command: "echo Stop triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_next:
|
||||||
|
command: "echo Next track"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_previous:
|
||||||
|
command: "echo Previous track"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_volume:
|
||||||
|
command: "echo Volume changed"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_mute:
|
||||||
|
command: "echo Mute toggled"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_seek:
|
||||||
|
command: "echo Seek triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
# Turn on/off/toggle (callback-only actions, no default behavior)
|
||||||
|
on_turn_on:
|
||||||
|
command: "echo PC turned on"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_turn_off:
|
||||||
|
command: "rundll32.exe user32.dll,LockWorkStation"
|
||||||
|
timeout: 5
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_toggle:
|
||||||
|
command: "echo Toggle triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Available callbacks:
|
||||||
|
|
||||||
|
| Callback | Triggered by | Description |
|
||||||
|
|----------|--------------|-------------|
|
||||||
|
| `on_play` | `/api/media/play` | After play succeeds |
|
||||||
|
| `on_pause` | `/api/media/pause` | After pause succeeds |
|
||||||
|
| `on_stop` | `/api/media/stop` | After stop succeeds |
|
||||||
|
| `on_next` | `/api/media/next` | After next track succeeds |
|
||||||
|
| `on_previous` | `/api/media/previous` | After previous track succeeds |
|
||||||
|
| `on_volume` | `/api/media/volume` | After volume change succeeds |
|
||||||
|
| `on_mute` | `/api/media/mute` | After mute toggle |
|
||||||
|
| `on_seek` | `/api/media/seek` | After seek succeeds |
|
||||||
|
| `on_turn_on` | `/api/media/turn_on` | Callback-only action |
|
||||||
|
| `on_turn_off` | `/api/media/turn_off` | Callback-only action |
|
||||||
|
| `on_toggle` | `/api/media/toggle` | Callback-only action |
|
||||||
|
|
||||||
|
Callback configuration options:
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `command` | Yes | Command to execute |
|
||||||
|
| `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
|
## Running as a Service
|
||||||
|
|
||||||
### Windows Task Scheduler (Recommended)
|
### Windows Task Scheduler (Recommended)
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
# Copy this file to config.yaml and customize as needed.
|
# Copy this file to config.yaml and customize as needed.
|
||||||
# A secure token will be auto-generated on first run if not specified.
|
# A secure token will be auto-generated on first run if not specified.
|
||||||
|
|
||||||
# API Token (generate a secure random token)
|
# API Tokens - Multiple tokens with friendly labels
|
||||||
api_token: "your-secure-token-here"
|
# This allows you to identify which client is making requests in the logs
|
||||||
|
api_tokens:
|
||||||
|
home_assistant: "your-home-assistant-token-here"
|
||||||
|
mobile: "your-mobile-app-token-here"
|
||||||
|
web_ui: "your-web-ui-token-here"
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
@@ -45,3 +49,63 @@ scripts:
|
|||||||
description: "Restart the PC immediately"
|
description: "Restart the PC immediately"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
|
# Callback scripts (executed after media actions)
|
||||||
|
# All callbacks are optional - if not defined, the action runs without callback
|
||||||
|
callbacks:
|
||||||
|
# Media control callbacks (run after successful action)
|
||||||
|
on_play:
|
||||||
|
command: "echo Play triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_pause:
|
||||||
|
command: "echo Pause triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_stop:
|
||||||
|
command: "echo Stop triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_next:
|
||||||
|
command: "echo Next track"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_previous:
|
||||||
|
command: "echo Previous track"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_volume:
|
||||||
|
command: "echo Volume changed"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_mute:
|
||||||
|
command: "echo Mute toggled"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_seek:
|
||||||
|
command: "echo Seek triggered"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
# Turn on/off/toggle (callback-only actions, no default behavior)
|
||||||
|
on_turn_on:
|
||||||
|
command: "echo Turn on callback"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_turn_off:
|
||||||
|
command: "rundll32.exe user32.dll,LockWorkStation"
|
||||||
|
timeout: 5
|
||||||
|
shell: true
|
||||||
|
|
||||||
|
on_toggle:
|
||||||
|
command: "echo Toggle callback"
|
||||||
|
timeout: 10
|
||||||
|
shell: true
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Authentication middleware and utilities."""
|
"""Authentication middleware and utilities."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from contextvars import ContextVar
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Query, Request, status
|
from fastapi import Depends, HTTPException, Query, Request, status
|
||||||
@@ -9,6 +11,24 @@ from .config import settings
|
|||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
# Context variable to store current request's token label
|
||||||
|
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_label(token: str) -> Optional[str]:
|
||||||
|
"""Get the label for a token. Returns None if token is invalid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The token to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The label for the token, or None if invalid
|
||||||
|
"""
|
||||||
|
for label, stored_token in settings.api_tokens.items():
|
||||||
|
if secrets.compare_digest(stored_token, token):
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(
|
async def verify_token(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -21,7 +41,7 @@ async def verify_token(
|
|||||||
credentials: The bearer token credentials
|
credentials: The bearer token credentials
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The validated token
|
The token label
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If the token is missing or invalid
|
HTTPException: If the token is missing or invalid
|
||||||
@@ -33,14 +53,17 @@ async def verify_token(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if credentials.credentials != settings.api_token:
|
label = get_token_label(credentials.credentials)
|
||||||
|
if label is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid authentication token",
|
detail="Invalid authentication token",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return credentials.credentials
|
# Set label in context for logging
|
||||||
|
token_label_var.set(label)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
class TokenAuth:
|
class TokenAuth:
|
||||||
@@ -54,7 +77,7 @@ class TokenAuth:
|
|||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Verify the token and return it or raise an exception."""
|
"""Verify the token and return the label or raise an exception."""
|
||||||
if credentials is None:
|
if credentials is None:
|
||||||
if self.auto_error:
|
if self.auto_error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -64,7 +87,8 @@ class TokenAuth:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if credentials.credentials != settings.api_token:
|
label = get_token_label(credentials.credentials)
|
||||||
|
if label is None:
|
||||||
if self.auto_error:
|
if self.auto_error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -73,7 +97,9 @@ class TokenAuth:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return credentials.credentials
|
# Set label in context for logging
|
||||||
|
token_label_var.set(label)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
async def verify_token_or_query(
|
async def verify_token_or_query(
|
||||||
@@ -89,23 +115,28 @@ async def verify_token_or_query(
|
|||||||
token: Token from query parameter
|
token: Token from query parameter
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The validated token
|
The token label
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: If the token is missing or invalid
|
HTTPException: If the token is missing or invalid
|
||||||
"""
|
"""
|
||||||
|
label = None
|
||||||
|
|
||||||
# Try header first
|
# Try header first
|
||||||
if credentials is not None:
|
if credentials is not None:
|
||||||
if credentials.credentials == settings.api_token:
|
label = get_token_label(credentials.credentials)
|
||||||
return credentials.credentials
|
|
||||||
|
|
||||||
# Try query parameter
|
# Try query parameter
|
||||||
if token is not None:
|
if label is None and token is not None:
|
||||||
if token == settings.api_token:
|
label = get_token_label(token)
|
||||||
return token
|
|
||||||
|
|
||||||
|
if label is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Missing or invalid authentication token",
|
detail="Missing or invalid authentication token",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set label in context for logging
|
||||||
|
token_label_var.set(label)
|
||||||
|
return label
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ from pydantic import BaseModel, Field
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackConfig(BaseModel):
|
||||||
|
"""Configuration for a callback script (no label/description needed)."""
|
||||||
|
|
||||||
|
command: str = Field(..., description="Command or script to execute")
|
||||||
|
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 ScriptConfig(BaseModel):
|
class ScriptConfig(BaseModel):
|
||||||
"""Configuration for a custom script."""
|
"""Configuration for a custom script."""
|
||||||
|
|
||||||
@@ -37,9 +46,9 @@ class Settings(BaseSettings):
|
|||||||
port: int = Field(default=8765, description="Server port")
|
port: int = Field(default=8765, description="Server port")
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
api_token: str = Field(
|
api_tokens: dict[str, str] = Field(
|
||||||
default_factory=lambda: secrets.token_urlsafe(32),
|
default_factory=lambda: {"default": secrets.token_urlsafe(32)},
|
||||||
description="API authentication token",
|
description="Named API tokens for access control (label: token pairs)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Media controller settings
|
# Media controller settings
|
||||||
@@ -47,6 +56,12 @@ class Settings(BaseSettings):
|
|||||||
default=1.0, description="Media status poll interval in seconds"
|
default=1.0, description="Media status poll interval in seconds"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Audio device settings
|
||||||
|
audio_device: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Audio device name to control (None = default device). Use /api/audio/devices to list available devices.",
|
||||||
|
)
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log_level: str = Field(default="INFO", description="Logging level")
|
log_level: str = Field(default="INFO", description="Logging level")
|
||||||
|
|
||||||
@@ -56,6 +71,12 @@ class Settings(BaseSettings):
|
|||||||
description="Custom scripts that can be executed via API",
|
description="Custom scripts that can be executed via API",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Callback scripts (executed by integration events, not shown in UI)
|
||||||
|
callbacks: dict[str, CallbackConfig] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Callback scripts executed by integration events (on_turn_on, on_turn_off, on_toggle)",
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
||||||
"""Load settings from a YAML configuration file."""
|
"""Load settings from a YAML configuration file."""
|
||||||
@@ -107,9 +128,14 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
|||||||
config = {
|
config = {
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
"api_token": secrets.token_urlsafe(32),
|
"api_tokens": {
|
||||||
|
"default": secrets.token_urlsafe(32),
|
||||||
|
},
|
||||||
"poll_interval": 1.0,
|
"poll_interval": 1.0,
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
|
# Audio device to control (use GET /api/audio/devices to list available devices)
|
||||||
|
# Set to null or remove to use default device
|
||||||
|
# "audio_device": "Speakers (Realtek",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"example_script": {
|
"example_script": {
|
||||||
"command": "echo Hello from Media Server!",
|
"command": "echo Hello from Media Server!",
|
||||||
|
|||||||
284
media_server/config_manager.py
Normal file
284
media_server/config_manager.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"""Thread-safe configuration file manager for runtime script updates."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .config import CallbackConfig, ScriptConfig, settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
"""Thread-safe configuration file manager."""
|
||||||
|
|
||||||
|
def __init__(self, config_path: Optional[Path] = None):
|
||||||
|
"""Initialize the config manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to config file. If None, will search standard locations.
|
||||||
|
"""
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._config_path = config_path or self._find_config_path()
|
||||||
|
logger.info(f"ConfigManager initialized with path: {self._config_path}")
|
||||||
|
|
||||||
|
def _find_config_path(self) -> Path:
|
||||||
|
"""Find the active config file path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the config file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If no config file is found.
|
||||||
|
"""
|
||||||
|
# Same search logic as Settings.load_from_yaml()
|
||||||
|
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():
|
||||||
|
return search_path
|
||||||
|
|
||||||
|
# If not found, use the default location
|
||||||
|
if os.name == "nt":
|
||||||
|
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
|
||||||
|
else:
|
||||||
|
default_path = Path.home() / ".config" / "media-server" / "config.yaml"
|
||||||
|
|
||||||
|
logger.warning(f"No config file found, using default path: {default_path}")
|
||||||
|
return default_path
|
||||||
|
|
||||||
|
def add_script(self, name: str, config: ScriptConfig) -> None:
|
||||||
|
"""Add a new script to config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Script name (must be unique).
|
||||||
|
config: Script configuration.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If script already exists.
|
||||||
|
IOError: If config file cannot be written.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Read YAML
|
||||||
|
if not self._config_path.exists():
|
||||||
|
data = {}
|
||||||
|
else:
|
||||||
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
# Check if script already exists
|
||||||
|
if "scripts" in data and name in data["scripts"]:
|
||||||
|
raise ValueError(f"Script '{name}' already exists")
|
||||||
|
|
||||||
|
# Add script
|
||||||
|
if "scripts" not in data:
|
||||||
|
data["scripts"] = {}
|
||||||
|
data["scripts"][name] = config.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# Write YAML
|
||||||
|
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
# Update in-memory settings
|
||||||
|
settings.scripts[name] = config
|
||||||
|
|
||||||
|
logger.info(f"Script '{name}' added to config")
|
||||||
|
|
||||||
|
def update_script(self, name: str, config: ScriptConfig) -> None:
|
||||||
|
"""Update an existing script.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Script name.
|
||||||
|
config: New script configuration.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If script does not exist.
|
||||||
|
IOError: If config file cannot be written.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Read YAML
|
||||||
|
if not self._config_path.exists():
|
||||||
|
raise ValueError(f"Config file not found: {self._config_path}")
|
||||||
|
|
||||||
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
# Check if script exists
|
||||||
|
if "scripts" not in data or name not in data["scripts"]:
|
||||||
|
raise ValueError(f"Script '{name}' does not exist")
|
||||||
|
|
||||||
|
# Update script
|
||||||
|
data["scripts"][name] = config.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# Write YAML
|
||||||
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
# Update in-memory settings
|
||||||
|
settings.scripts[name] = config
|
||||||
|
|
||||||
|
logger.info(f"Script '{name}' updated in config")
|
||||||
|
|
||||||
|
def delete_script(self, name: str) -> None:
|
||||||
|
"""Delete a script from config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Script name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If script does not exist.
|
||||||
|
IOError: If config file cannot be written.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Read YAML
|
||||||
|
if not self._config_path.exists():
|
||||||
|
raise ValueError(f"Config file not found: {self._config_path}")
|
||||||
|
|
||||||
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
# Check if script exists
|
||||||
|
if "scripts" not in data or name not in data["scripts"]:
|
||||||
|
raise ValueError(f"Script '{name}' does not exist")
|
||||||
|
|
||||||
|
# Delete script
|
||||||
|
del data["scripts"][name]
|
||||||
|
|
||||||
|
# Write YAML
|
||||||
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
# Update in-memory settings
|
||||||
|
if name in settings.scripts:
|
||||||
|
del settings.scripts[name]
|
||||||
|
|
||||||
|
logger.info(f"Script '{name}' deleted from config")
|
||||||
|
|
||||||
|
def add_callback(self, name: str, config: CallbackConfig) -> None:
|
||||||
|
"""Add a new callback to config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Callback name (must be unique).
|
||||||
|
config: Callback configuration.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If callback already exists.
|
||||||
|
IOError: If config file cannot be written.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Read YAML
|
||||||
|
if not self._config_path.exists():
|
||||||
|
data = {}
|
||||||
|
else:
|
||||||
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
# Check if callback already exists
|
||||||
|
if "callbacks" in data and name in data["callbacks"]:
|
||||||
|
raise ValueError(f"Callback '{name}' already exists")
|
||||||
|
|
||||||
|
# Add callback
|
||||||
|
if "callbacks" not in data:
|
||||||
|
data["callbacks"] = {}
|
||||||
|
data["callbacks"][name] = config.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# Write YAML
|
||||||
|
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
# Update in-memory settings
|
||||||
|
settings.callbacks[name] = config
|
||||||
|
|
||||||
|
logger.info(f"Callback '{name}' added to config")
|
||||||
|
|
||||||
|
def update_callback(self, name: str, config: CallbackConfig) -> None:
|
||||||
|
"""Update an existing callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Callback name.
|
||||||
|
config: New callback configuration.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If callback does not exist.
|
||||||
|
IOError: If config file cannot be written.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Read YAML
|
||||||
|
if not self._config_path.exists():
|
||||||
|
raise ValueError(f"Config file not found: {self._config_path}")
|
||||||
|
|
||||||
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
# Check if callback exists
|
||||||
|
if "callbacks" not in data or name not in data["callbacks"]:
|
||||||
|
raise ValueError(f"Callback '{name}' does not exist")
|
||||||
|
|
||||||
|
# Update callback
|
||||||
|
data["callbacks"][name] = config.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# Write YAML
|
||||||
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
# Update in-memory settings
|
||||||
|
settings.callbacks[name] = config
|
||||||
|
|
||||||
|
logger.info(f"Callback '{name}' updated in config")
|
||||||
|
|
||||||
|
def delete_callback(self, name: str) -> None:
|
||||||
|
"""Delete a callback from config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Callback name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If callback does not exist.
|
||||||
|
IOError: If config file cannot be written.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Read YAML
|
||||||
|
if not self._config_path.exists():
|
||||||
|
raise ValueError(f"Config file not found: {self._config_path}")
|
||||||
|
|
||||||
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
# Check if callback exists
|
||||||
|
if "callbacks" not in data or name not in data["callbacks"]:
|
||||||
|
raise ValueError(f"Callback '{name}' does not exist")
|
||||||
|
|
||||||
|
# Delete callback
|
||||||
|
del data["callbacks"][name]
|
||||||
|
|
||||||
|
# Write YAML
|
||||||
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
# Update in-memory settings
|
||||||
|
if name in settings.callbacks:
|
||||||
|
del settings.callbacks[name]
|
||||||
|
|
||||||
|
logger.info(f"Callback '{name}' deleted from config")
|
||||||
|
|
||||||
|
|
||||||
|
# Global config manager instance
|
||||||
|
config_manager = ConfigManager()
|
||||||
@@ -4,24 +4,41 @@ import argparse
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .auth import get_token_label, token_label_var
|
||||||
from .config import settings, generate_default_config, get_config_dir
|
from .config import settings, generate_default_config, get_config_dir
|
||||||
from .routes import health_router, media_router, scripts_router
|
from .routes import audio_router, callbacks_router, health_router, media_router, scripts_router
|
||||||
from .services import get_media_controller
|
from .services import get_media_controller
|
||||||
from .services.websocket_manager import ws_manager
|
from .services.websocket_manager import ws_manager
|
||||||
|
|
||||||
|
|
||||||
|
class TokenLabelFilter(logging.Filter):
|
||||||
|
"""Add token label to log records."""
|
||||||
|
|
||||||
|
def filter(self, record):
|
||||||
|
record.token_label = token_label_var.get("unknown")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
"""Configure application logging."""
|
"""Configure application logging with token labels."""
|
||||||
|
# Create filter and handler
|
||||||
|
token_filter = TokenLabelFilter()
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.addFilter(token_filter)
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, settings.log_level.upper()),
|
level=getattr(logging, settings.log_level.upper()),
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s",
|
||||||
handlers=[logging.StreamHandler(sys.stdout)],
|
handlers=[handler],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +48,10 @@ async def lifespan(app: FastAPI):
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||||
logger.info(f"API Token: {settings.api_token[:8]}...")
|
|
||||||
|
# Log all configured tokens
|
||||||
|
for label, token in settings.api_tokens.items():
|
||||||
|
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||||
|
|
||||||
# Start WebSocket status monitor
|
# Start WebSocket status monitor
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
@@ -63,11 +83,48 @@ def create_app() -> FastAPI:
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add token logging middleware
|
||||||
|
@app.middleware("http")
|
||||||
|
async def token_logging_middleware(request: Request, call_next):
|
||||||
|
"""Extract token label and set in context for logging."""
|
||||||
|
token_label = "unknown"
|
||||||
|
|
||||||
|
# Try Authorization header
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header[7:]
|
||||||
|
label = get_token_label(token)
|
||||||
|
if label:
|
||||||
|
token_label = label
|
||||||
|
|
||||||
|
# Try query parameter (for artwork endpoint)
|
||||||
|
elif "token" in request.query_params:
|
||||||
|
token = request.query_params["token"]
|
||||||
|
label = get_token_label(token)
|
||||||
|
if label:
|
||||||
|
token_label = label
|
||||||
|
|
||||||
|
token_label_var.set(token_label)
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
|
app.include_router(audio_router)
|
||||||
|
app.include_router(callbacks_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(media_router)
|
app.include_router(media_router)
|
||||||
app.include_router(scripts_router)
|
app.include_router(scripts_router)
|
||||||
|
|
||||||
|
# Mount static files and serve UI at root
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
if static_dir.exists():
|
||||||
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def serve_ui():
|
||||||
|
"""Serve the Web UI."""
|
||||||
|
return FileResponse(static_dir / "index.html")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -108,8 +165,10 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
if args.show_token:
|
if args.show_token:
|
||||||
print(f"API Token: {settings.api_token}")
|
|
||||||
print(f"Config directory: {get_config_dir()}")
|
print(f"Config directory: {get_config_dir()}")
|
||||||
|
print(f"\nAPI Tokens:")
|
||||||
|
for label, token in settings.api_tokens.items():
|
||||||
|
print(f" {label:20} {token}")
|
||||||
return
|
return
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""API route modules."""
|
"""API route modules."""
|
||||||
|
|
||||||
|
from .audio import router as audio_router
|
||||||
|
from .callbacks import router as callbacks_router
|
||||||
from .health import router as health_router
|
from .health import router as health_router
|
||||||
from .media import router as media_router
|
from .media import router as media_router
|
||||||
from .scripts import router as scripts_router
|
from .scripts import router as scripts_router
|
||||||
|
|
||||||
__all__ = ["health_router", "media_router", "scripts_router"]
|
__all__ = ["audio_router", "callbacks_router", "health_router", "media_router", "scripts_router"]
|
||||||
|
|||||||
28
media_server/routes/audio.py
Normal file
28
media_server/routes/audio.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Audio device API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from ..auth import verify_token
|
||||||
|
from ..services import get_audio_devices
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/audio", tags=["audio"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices")
|
||||||
|
async def list_audio_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]:
|
||||||
|
"""List available audio output devices.
|
||||||
|
|
||||||
|
Returns a list of audio devices with their IDs and friendly names.
|
||||||
|
Use the device name in the `audio_device` config option to control
|
||||||
|
a specific device instead of the default one.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of audio devices with id and name
|
||||||
|
"""
|
||||||
|
devices = get_audio_devices()
|
||||||
|
logger.debug("Found %d audio devices", len(devices))
|
||||||
|
return devices
|
||||||
339
media_server/routes/callbacks.py
Normal file
339
media_server/routes/callbacks.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"""Callback management API endpoints."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..auth import verify_token
|
||||||
|
from ..config import CallbackConfig, settings
|
||||||
|
from ..config_manager import config_manager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackInfo(BaseModel):
|
||||||
|
"""Information about a configured callback."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
command: str
|
||||||
|
timeout: int
|
||||||
|
working_dir: str | None = None
|
||||||
|
shell: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackCreateRequest(BaseModel):
|
||||||
|
"""Request model for creating or updating a callback."""
|
||||||
|
|
||||||
|
command: str = Field(..., description="Command to execute", min_length=1)
|
||||||
|
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
|
||||||
|
working_dir: str | None = Field(default=None, description="Working directory")
|
||||||
|
shell: bool = Field(default=True, description="Run command in shell")
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackExecuteResponse(BaseModel):
|
||||||
|
"""Response model for callback execution."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
callback: str
|
||||||
|
exit_code: int | None = None
|
||||||
|
stdout: str = ""
|
||||||
|
stderr: str = ""
|
||||||
|
error: str | None = None
|
||||||
|
execution_time: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_callback_name(name: str) -> None:
|
||||||
|
"""Validate callback name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Callback name to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If name is invalid.
|
||||||
|
"""
|
||||||
|
# All available callback events
|
||||||
|
valid_names = {
|
||||||
|
"on_play",
|
||||||
|
"on_pause",
|
||||||
|
"on_stop",
|
||||||
|
"on_next",
|
||||||
|
"on_previous",
|
||||||
|
"on_volume",
|
||||||
|
"on_mute",
|
||||||
|
"on_seek",
|
||||||
|
"on_turn_on",
|
||||||
|
"on_turn_off",
|
||||||
|
"on_toggle",
|
||||||
|
}
|
||||||
|
|
||||||
|
if name not in valid_names:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Callback name must be one of: {', '.join(sorted(valid_names))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
|
||||||
|
"""List all configured callbacks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of configured callbacks.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
CallbackInfo(
|
||||||
|
name=name,
|
||||||
|
command=config.command,
|
||||||
|
timeout=config.timeout,
|
||||||
|
working_dir=config.working_dir,
|
||||||
|
shell=config.shell,
|
||||||
|
)
|
||||||
|
for name, config in settings.callbacks.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute/{callback_name}")
|
||||||
|
async def execute_callback(
|
||||||
|
callback_name: str,
|
||||||
|
_: str = Depends(verify_token),
|
||||||
|
) -> CallbackExecuteResponse:
|
||||||
|
"""Execute a callback for debugging purposes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback_name: Name of the callback to execute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Execution result including stdout, stderr, and exit code
|
||||||
|
"""
|
||||||
|
# Validate callback name
|
||||||
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
|
# Check if callback exists
|
||||||
|
if callback_name not in settings.callbacks:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Callback '{callback_name}' not found. Use /api/callbacks/list to see configured callbacks.",
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_config = settings.callbacks[callback_name]
|
||||||
|
|
||||||
|
logger.info(f"Executing callback for debugging: {callback_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute in thread pool to not block
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: _run_callback(
|
||||||
|
command=callback_config.command,
|
||||||
|
timeout=callback_config.timeout,
|
||||||
|
shell=callback_config.shell,
|
||||||
|
working_dir=callback_config.working_dir,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CallbackExecuteResponse(
|
||||||
|
success=result["exit_code"] == 0,
|
||||||
|
callback=callback_name,
|
||||||
|
exit_code=result["exit_code"],
|
||||||
|
stdout=result["stdout"],
|
||||||
|
stderr=result["stderr"],
|
||||||
|
execution_time=result.get("execution_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Callback execution error: {e}")
|
||||||
|
return CallbackExecuteResponse(
|
||||||
|
success=False,
|
||||||
|
callback=callback_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_callback(
|
||||||
|
command: str,
|
||||||
|
timeout: int,
|
||||||
|
shell: bool,
|
||||||
|
working_dir: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run a callback 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, execution_time
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=shell,
|
||||||
|
cwd=working_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
execution_time = time.time() - start_time
|
||||||
|
return {
|
||||||
|
"exit_code": result.returncode,
|
||||||
|
"stdout": result.stdout[:10000], # Limit output size
|
||||||
|
"stderr": result.stderr[:10000],
|
||||||
|
"execution_time": execution_time,
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
execution_time = time.time() - start_time
|
||||||
|
return {
|
||||||
|
"exit_code": -1,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": f"Callback timed out after {timeout} seconds",
|
||||||
|
"execution_time": execution_time,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
execution_time = time.time() - start_time
|
||||||
|
return {
|
||||||
|
"exit_code": -1,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": str(e),
|
||||||
|
"execution_time": execution_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create/{callback_name}")
|
||||||
|
async def create_callback(
|
||||||
|
callback_name: str,
|
||||||
|
request: CallbackCreateRequest,
|
||||||
|
_: str = Depends(verify_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback_name: Callback event name (on_turn_on, on_turn_off, on_toggle).
|
||||||
|
request: Callback configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with callback name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If callback already exists or name is invalid.
|
||||||
|
"""
|
||||||
|
# Validate name
|
||||||
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
|
# Check if callback already exists
|
||||||
|
if callback_name in settings.callbacks:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Callback '{callback_name}' already exists. Use PUT /api/callbacks/update/{callback_name} to update it.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create callback config
|
||||||
|
callback_config = CallbackConfig(**request.model_dump())
|
||||||
|
|
||||||
|
# Add to config file and in-memory
|
||||||
|
try:
|
||||||
|
config_manager.add_callback(callback_name, callback_config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add callback '{callback_name}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to add callback: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Callback '{callback_name}' created successfully")
|
||||||
|
return {"success": True, "callback": callback_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/update/{callback_name}")
|
||||||
|
async def update_callback(
|
||||||
|
callback_name: str,
|
||||||
|
request: CallbackCreateRequest,
|
||||||
|
_: str = Depends(verify_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update an existing callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback_name: Callback event name.
|
||||||
|
request: Updated callback configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with callback name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If callback does not exist.
|
||||||
|
"""
|
||||||
|
# Validate name
|
||||||
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
|
# Check if callback exists
|
||||||
|
if callback_name not in settings.callbacks:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Callback '{callback_name}' not found. Use POST /api/callbacks/create/{callback_name} to create it.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create updated callback config
|
||||||
|
callback_config = CallbackConfig(**request.model_dump())
|
||||||
|
|
||||||
|
# Update config file and in-memory
|
||||||
|
try:
|
||||||
|
config_manager.update_callback(callback_name, callback_config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update callback '{callback_name}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to update callback: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Callback '{callback_name}' updated successfully")
|
||||||
|
return {"success": True, "callback": callback_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/delete/{callback_name}")
|
||||||
|
async def delete_callback(
|
||||||
|
callback_name: str,
|
||||||
|
_: str = Depends(verify_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delete a callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback_name: Callback event name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with callback name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If callback does not exist.
|
||||||
|
"""
|
||||||
|
# Validate name
|
||||||
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
|
# Check if callback exists
|
||||||
|
if callback_name not in settings.callbacks:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Callback '{callback_name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete from config file and in-memory
|
||||||
|
try:
|
||||||
|
config_manager.delete_callback(callback_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete callback '{callback_name}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete callback: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Callback '{callback_name}' deleted successfully")
|
||||||
|
return {"success": True, "callback": callback_name}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Media control API endpoints."""
|
"""Media control API endpoints."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||||
@@ -17,6 +18,33 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/media", tags=["media"])
|
router = APIRouter(prefix="/api/media", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_callback(callback_name: str) -> None:
|
||||||
|
"""Run a callback if configured. Failures are logged but don't raise."""
|
||||||
|
if not settings.callbacks or callback_name not in settings.callbacks:
|
||||||
|
return
|
||||||
|
|
||||||
|
from .scripts import _run_script
|
||||||
|
|
||||||
|
callback = settings.callbacks[callback_name]
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: _run_script(
|
||||||
|
command=callback.command,
|
||||||
|
timeout=callback.timeout,
|
||||||
|
shell=callback.shell,
|
||||||
|
working_dir=callback.working_dir,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if result["exit_code"] != 0:
|
||||||
|
logger.warning(
|
||||||
|
"Callback %s failed with exit code %s: %s",
|
||||||
|
callback_name,
|
||||||
|
result["exit_code"],
|
||||||
|
result["stderr"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=MediaStatus)
|
@router.get("/status", response_model=MediaStatus)
|
||||||
async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus:
|
async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus:
|
||||||
"""Get current media playback status.
|
"""Get current media playback status.
|
||||||
@@ -42,6 +70,7 @@ async def play(_: str = Depends(verify_token)) -> dict:
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Failed to start playback - no active media session",
|
detail="Failed to start playback - no active media session",
|
||||||
)
|
)
|
||||||
|
await _run_callback("on_play")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +88,7 @@ async def pause(_: str = Depends(verify_token)) -> dict:
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Failed to pause - no active media session",
|
detail="Failed to pause - no active media session",
|
||||||
)
|
)
|
||||||
|
await _run_callback("on_pause")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +106,7 @@ async def stop(_: str = Depends(verify_token)) -> dict:
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Failed to stop - no active media session",
|
detail="Failed to stop - no active media session",
|
||||||
)
|
)
|
||||||
|
await _run_callback("on_stop")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +124,7 @@ async def next_track(_: str = Depends(verify_token)) -> dict:
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Failed to skip - no active media session",
|
detail="Failed to skip - no active media session",
|
||||||
)
|
)
|
||||||
|
await _run_callback("on_next")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +142,7 @@ async def previous_track(_: str = Depends(verify_token)) -> dict:
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Failed to go back - no active media session",
|
detail="Failed to go back - no active media session",
|
||||||
)
|
)
|
||||||
|
await _run_callback("on_previous")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -132,6 +165,7 @@ async def set_volume(
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Failed to set volume",
|
detail="Failed to set volume",
|
||||||
)
|
)
|
||||||
|
await _run_callback("on_volume")
|
||||||
return {"success": True, "volume": request.volume}
|
return {"success": True, "volume": request.volume}
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +178,7 @@ async def toggle_mute(_: str = Depends(verify_token)) -> dict:
|
|||||||
"""
|
"""
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
muted = await controller.toggle_mute()
|
muted = await controller.toggle_mute()
|
||||||
|
await _run_callback("on_mute")
|
||||||
return {"success": True, "muted": muted}
|
return {"success": True, "muted": muted}
|
||||||
|
|
||||||
|
|
||||||
@@ -164,9 +199,43 @@ async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict:
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Failed to seek - no active media session or seek not supported",
|
detail="Failed to seek - no active media session or seek not supported",
|
||||||
)
|
)
|
||||||
|
await _run_callback("on_seek")
|
||||||
return {"success": True, "position": request.position}
|
return {"success": True, "position": request.position}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/turn_on")
|
||||||
|
async def turn_on(_: str = Depends(verify_token)) -> dict:
|
||||||
|
"""Execute turn on callback if configured.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
await _run_callback("on_turn_on")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/turn_off")
|
||||||
|
async def turn_off(_: str = Depends(verify_token)) -> dict:
|
||||||
|
"""Execute turn off callback if configured.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
await _run_callback("on_turn_off")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/toggle")
|
||||||
|
async def toggle(_: str = Depends(verify_token)) -> dict:
|
||||||
|
"""Execute toggle callback if configured.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success status
|
||||||
|
"""
|
||||||
|
await _run_callback("on_toggle")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/artwork")
|
@router.get("/artwork")
|
||||||
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
||||||
"""Get the current album artwork.
|
"""Get the current album artwork.
|
||||||
@@ -213,10 +282,16 @@ async def websocket_endpoint(
|
|||||||
- {"type": "get_status"} - Request current status
|
- {"type": "get_status"} - Request current status
|
||||||
"""
|
"""
|
||||||
# Verify token
|
# Verify token
|
||||||
if token != settings.api_token:
|
from ..auth import get_token_label, token_label_var
|
||||||
|
|
||||||
|
label = get_token_label(token) if token else None
|
||||||
|
if label is None:
|
||||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
await websocket.close(code=4001, reason="Invalid authentication token")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Set label in context for logging
|
||||||
|
token_label_var.set(label)
|
||||||
|
|
||||||
await ws_manager.connect(websocket)
|
await ws_manager.connect(websocket)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import verify_token
|
from ..auth import verify_token
|
||||||
from ..config import settings
|
from ..config import ScriptConfig, settings
|
||||||
|
from ..config_manager import config_manager
|
||||||
|
from ..services.websocket_manager import ws_manager
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,6 +34,7 @@ class ScriptExecuteResponse(BaseModel):
|
|||||||
stdout: str = ""
|
stdout: str = ""
|
||||||
stderr: str = ""
|
stderr: str = ""
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
execution_time: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class ScriptInfo(BaseModel):
|
class ScriptInfo(BaseModel):
|
||||||
@@ -37,6 +42,7 @@ class ScriptInfo(BaseModel):
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
label: str
|
label: str
|
||||||
|
command: str
|
||||||
description: str
|
description: str
|
||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
timeout: int
|
timeout: int
|
||||||
@@ -53,6 +59,7 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
|
|||||||
ScriptInfo(
|
ScriptInfo(
|
||||||
name=name,
|
name=name,
|
||||||
label=config.label or name.replace("_", " ").title(),
|
label=config.label or name.replace("_", " ").title(),
|
||||||
|
command=config.command,
|
||||||
description=config.description,
|
description=config.description,
|
||||||
icon=config.icon,
|
icon=config.icon,
|
||||||
timeout=config.timeout,
|
timeout=config.timeout,
|
||||||
@@ -82,7 +89,6 @@ async def execute_script(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
|
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
|
||||||
)
|
)
|
||||||
|
|
||||||
script_config = settings.scripts[script_name]
|
script_config = settings.scripts[script_name]
|
||||||
args = request.args if request else []
|
args = request.args if request else []
|
||||||
|
|
||||||
@@ -113,6 +119,7 @@ async def execute_script(
|
|||||||
exit_code=result["exit_code"],
|
exit_code=result["exit_code"],
|
||||||
stdout=result["stdout"],
|
stdout=result["stdout"],
|
||||||
stderr=result["stderr"],
|
stderr=result["stderr"],
|
||||||
|
execution_time=result.get("execution_time"),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -139,8 +146,9 @@ def _run_script(
|
|||||||
working_dir: Working directory
|
working_dir: Working directory
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with exit_code, stdout, stderr
|
Dict with exit_code, stdout, stderr, execution_time
|
||||||
"""
|
"""
|
||||||
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
@@ -150,20 +158,208 @@ def _run_script(
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
execution_time = time.time() - start_time
|
||||||
return {
|
return {
|
||||||
"exit_code": result.returncode,
|
"exit_code": result.returncode,
|
||||||
"stdout": result.stdout[:10000], # Limit output size
|
"stdout": result.stdout[:10000], # Limit output size
|
||||||
"stderr": result.stderr[:10000],
|
"stderr": result.stderr[:10000],
|
||||||
|
"execution_time": execution_time,
|
||||||
}
|
}
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
execution_time = time.time() - start_time
|
||||||
return {
|
return {
|
||||||
"exit_code": -1,
|
"exit_code": -1,
|
||||||
"stdout": "",
|
"stdout": "",
|
||||||
"stderr": f"Script timed out after {timeout} seconds",
|
"stderr": f"Script timed out after {timeout} seconds",
|
||||||
|
"execution_time": execution_time,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
execution_time = time.time() - start_time
|
||||||
return {
|
return {
|
||||||
"exit_code": -1,
|
"exit_code": -1,
|
||||||
"stdout": "",
|
"stdout": "",
|
||||||
"stderr": str(e),
|
"stderr": str(e),
|
||||||
|
"execution_time": execution_time,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Script management endpoints
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptCreateRequest(BaseModel):
|
||||||
|
"""Request model for creating or updating a script."""
|
||||||
|
|
||||||
|
command: str = Field(..., description="Command to execute", min_length=1)
|
||||||
|
label: str | None = Field(default=None, description="User-friendly label")
|
||||||
|
description: str = Field(default="", description="Script description")
|
||||||
|
icon: str | None = Field(default=None, description="Custom MDI icon (e.g., 'mdi:power')")
|
||||||
|
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
|
||||||
|
working_dir: str | None = Field(default=None, description="Working directory")
|
||||||
|
shell: bool = Field(default=True, description="Run command in shell")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_script_name(name: str) -> None:
|
||||||
|
"""Validate script name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Script name to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If name is invalid.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Script name cannot be empty",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not re.match(r"^[a-zA-Z0-9_]+$", name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Script name must contain only alphanumeric characters and underscores",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(name) > 64:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Script name must be 64 characters or less",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create/{script_name}")
|
||||||
|
async def create_script(
|
||||||
|
script_name: str,
|
||||||
|
request: ScriptCreateRequest,
|
||||||
|
_: str = Depends(verify_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new script.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script_name: Name for the new script (alphanumeric + underscore only).
|
||||||
|
request: Script configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with script name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If script already exists or name is invalid.
|
||||||
|
"""
|
||||||
|
# Validate name
|
||||||
|
_validate_script_name(script_name)
|
||||||
|
|
||||||
|
# Check if script already exists
|
||||||
|
if script_name in settings.scripts:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create script config
|
||||||
|
script_config = ScriptConfig(**request.model_dump())
|
||||||
|
|
||||||
|
# Add to config file and in-memory
|
||||||
|
try:
|
||||||
|
config_manager.add_script(script_name, script_config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add script '{script_name}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to add script: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify WebSocket clients
|
||||||
|
await ws_manager.broadcast_scripts_changed()
|
||||||
|
|
||||||
|
logger.info(f"Script '{script_name}' created successfully")
|
||||||
|
return {"success": True, "script": script_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/update/{script_name}")
|
||||||
|
async def update_script(
|
||||||
|
script_name: str,
|
||||||
|
request: ScriptCreateRequest,
|
||||||
|
_: str = Depends(verify_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update an existing script.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script_name: Name of the script to update.
|
||||||
|
request: Updated script configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with script name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If script does not exist.
|
||||||
|
"""
|
||||||
|
# Validate name
|
||||||
|
_validate_script_name(script_name)
|
||||||
|
|
||||||
|
# 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 POST /api/scripts/create/{script_name} to create it.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create updated script config
|
||||||
|
script_config = ScriptConfig(**request.model_dump())
|
||||||
|
|
||||||
|
# Update config file and in-memory
|
||||||
|
try:
|
||||||
|
config_manager.update_script(script_name, script_config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update script '{script_name}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to update script: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify WebSocket clients
|
||||||
|
await ws_manager.broadcast_scripts_changed()
|
||||||
|
|
||||||
|
logger.info(f"Script '{script_name}' updated successfully")
|
||||||
|
return {"success": True, "script": script_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/delete/{script_name}")
|
||||||
|
async def delete_script(
|
||||||
|
script_name: str,
|
||||||
|
_: str = Depends(verify_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delete a script.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script_name: Name of the script to delete.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with script name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If script does not exist.
|
||||||
|
"""
|
||||||
|
# Validate name
|
||||||
|
_validate_script_name(script_name)
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete from config file and in-memory
|
||||||
|
try:
|
||||||
|
config_manager.delete_script(script_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete script '{script_name}': {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete script: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify WebSocket clients
|
||||||
|
await ws_manager.broadcast_scripts_changed()
|
||||||
|
|
||||||
|
logger.info(f"Script '{script_name}' deleted successfully")
|
||||||
|
return {"success": True, "script": script_name}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
# Get the project root directory (two levels up from this script)
|
# Get the project root directory (two levels up from this script)
|
||||||
$projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName
|
$projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName
|
||||||
|
|
||||||
$action = New-ScheduledTaskAction -Execute "python" -Argument "-m media_server.main" -WorkingDirectory $projectRoot
|
# Find Python executable
|
||||||
|
$pythonPath = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $pythonPath) {
|
||||||
|
Write-Error "Python not found in PATH. Please ensure Python is installed and accessible."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = New-ScheduledTaskAction -Execute $pythonPath -Argument "-m media_server.main" -WorkingDirectory $projectRoot
|
||||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest
|
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest
|
||||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ def get_media_controller() -> "MediaController":
|
|||||||
|
|
||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
from .windows_media import WindowsMediaController
|
from .windows_media import WindowsMediaController
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
_controller_instance = WindowsMediaController()
|
_controller_instance = WindowsMediaController(audio_device=settings.audio_device)
|
||||||
elif system == "Linux":
|
elif system == "Linux":
|
||||||
# Check if running on Android
|
# Check if running on Android
|
||||||
if _is_android():
|
if _is_android():
|
||||||
@@ -72,4 +73,13 @@ def get_current_album_art() -> bytes | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["get_media_controller", "get_current_album_art"]
|
def get_audio_devices() -> list[dict[str, str]]:
|
||||||
|
"""Get list of available audio output devices (Windows only for now)."""
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Windows":
|
||||||
|
from .windows_media import WindowsMediaController
|
||||||
|
return WindowsMediaController.get_audio_devices()
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"]
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ class ConnectionManager:
|
|||||||
for ws in disconnected:
|
for ws in disconnected:
|
||||||
await self.disconnect(ws)
|
await self.disconnect(ws)
|
||||||
|
|
||||||
|
async def broadcast_scripts_changed(self) -> None:
|
||||||
|
"""Notify all connected clients that scripts have changed."""
|
||||||
|
message = {"type": "scripts_changed", "data": {}}
|
||||||
|
await self.broadcast(message)
|
||||||
|
logger.info("Broadcast sent: scripts_changed")
|
||||||
|
|
||||||
def status_changed(
|
def status_changed(
|
||||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -54,41 +54,100 @@ except ImportError:
|
|||||||
# Volume control imports
|
# Volume control imports
|
||||||
PYCAW_AVAILABLE = False
|
PYCAW_AVAILABLE = False
|
||||||
_volume_control = None
|
_volume_control = None
|
||||||
|
_configured_device_name: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ctypes import cast, POINTER
|
from ctypes import cast, POINTER
|
||||||
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
|
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
|
||||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||||
|
|
||||||
def _init_volume_control():
|
import warnings
|
||||||
"""Initialize volume control interface."""
|
# Suppress pycaw warnings about missing device properties
|
||||||
global _volume_control
|
warnings.filterwarnings("ignore", category=UserWarning, module="pycaw")
|
||||||
if _volume_control is not None:
|
|
||||||
return _volume_control
|
def _get_all_audio_devices() -> list[dict[str, str]]:
|
||||||
|
"""Get list of all audio output devices."""
|
||||||
|
devices = []
|
||||||
try:
|
try:
|
||||||
devices = AudioUtilities.GetSpeakers()
|
# Use pycaw's GetAllDevices which handles property retrieval
|
||||||
interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
all_devices = AudioUtilities.GetAllDevices()
|
||||||
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
|
for device in all_devices:
|
||||||
return _volume_control
|
# Only include render (output) devices with valid names
|
||||||
except AttributeError:
|
# Render devices have IDs starting with {0.0.0
|
||||||
# Try accessing the underlying device
|
if device.FriendlyName and device.id and device.id.startswith("{0.0.0"):
|
||||||
|
devices.append({
|
||||||
|
"id": device.id,
|
||||||
|
"name": device.FriendlyName,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error enumerating audio devices: {e}")
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def _find_device_by_name(device_name: str):
|
||||||
|
"""Find an audio device by its friendly name (partial match).
|
||||||
|
|
||||||
|
Returns the AudioDevice wrapper for the matched device.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
devices = AudioUtilities.GetSpeakers()
|
# Get all devices and find matching one
|
||||||
if hasattr(devices, '_dev'):
|
all_devices = AudioUtilities.GetAllDevices()
|
||||||
interface = devices._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
for device in all_devices:
|
||||||
|
if device.FriendlyName and device_name.lower() in device.FriendlyName.lower():
|
||||||
|
logger.info(f"Found audio device: {device.FriendlyName}")
|
||||||
|
return device
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding device by name: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _init_volume_control(device_name: str | None = None):
|
||||||
|
"""Initialize volume control interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name: Name of the audio device to control (partial match).
|
||||||
|
If None, uses the default audio device.
|
||||||
|
"""
|
||||||
|
global _volume_control, _configured_device_name
|
||||||
|
if _volume_control is not None and device_name == _configured_device_name:
|
||||||
|
return _volume_control
|
||||||
|
|
||||||
|
_configured_device_name = device_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
if device_name:
|
||||||
|
# Find specific device by name
|
||||||
|
device = _find_device_by_name(device_name)
|
||||||
|
if device is None:
|
||||||
|
logger.warning(f"Audio device '{device_name}' not found, using default")
|
||||||
|
device = AudioUtilities.GetSpeakers()
|
||||||
|
else:
|
||||||
|
# Use default device
|
||||||
|
device = AudioUtilities.GetSpeakers()
|
||||||
|
|
||||||
|
if hasattr(device, 'Activate'):
|
||||||
|
interface = device.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
||||||
|
elif hasattr(device, '_dev'):
|
||||||
|
interface = device._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not activate audio device")
|
||||||
|
return None
|
||||||
|
|
||||||
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
|
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
|
||||||
return _volume_control
|
return _volume_control
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Volume control init failed: {e}")
|
logger.error(f"Volume control init error: {e}")
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Volume control init error: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
PYCAW_AVAILABLE = True
|
PYCAW_AVAILABLE = True
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"pycaw not available: {e}")
|
logger.warning(f"pycaw not available: {e}")
|
||||||
|
|
||||||
def _init_volume_control():
|
def _get_all_audio_devices() -> list[dict[str, str]]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _find_device_by_name(device_name: str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _init_volume_control(device_name: str | None = None):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
||||||
@@ -427,25 +486,38 @@ def _sync_seek(position: float) -> bool:
|
|||||||
class WindowsMediaController(MediaController):
|
class WindowsMediaController(MediaController):
|
||||||
"""Media controller for Windows using WinRT and pycaw."""
|
"""Media controller for Windows using WinRT and pycaw."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, audio_device: str | None = None):
|
||||||
|
"""Initialize the Windows media controller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_device: Name of the audio device to control (partial match).
|
||||||
|
If None, uses the default audio device.
|
||||||
|
"""
|
||||||
if not WINDOWS_AVAILABLE:
|
if not WINDOWS_AVAILABLE:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Windows media control requires winsdk, pycaw, and comtypes packages"
|
"Windows media control requires winsdk, pycaw, and comtypes packages"
|
||||||
)
|
)
|
||||||
self._volume_interface = None
|
self._volume_interface = None
|
||||||
self._volume_init_attempted = False
|
self._volume_init_attempted = False
|
||||||
|
self._audio_device = audio_device
|
||||||
|
|
||||||
def _get_volume_interface(self):
|
def _get_volume_interface(self):
|
||||||
"""Get the audio endpoint volume interface."""
|
"""Get the audio endpoint volume interface."""
|
||||||
if not self._volume_init_attempted:
|
if not self._volume_init_attempted:
|
||||||
self._volume_init_attempted = True
|
self._volume_init_attempted = True
|
||||||
self._volume_interface = _init_volume_control()
|
self._volume_interface = _init_volume_control(self._audio_device)
|
||||||
if self._volume_interface:
|
if self._volume_interface:
|
||||||
logger.info("Volume control initialized successfully")
|
device_info = f" (device: {self._audio_device})" if self._audio_device else " (default device)"
|
||||||
|
logger.info(f"Volume control initialized successfully{device_info}")
|
||||||
else:
|
else:
|
||||||
logger.warning("Volume control not available")
|
logger.warning("Volume control not available")
|
||||||
return self._volume_interface
|
return self._volume_interface
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_audio_devices() -> list[dict[str, str]]:
|
||||||
|
"""Get list of available audio output devices."""
|
||||||
|
return _get_all_audio_devices()
|
||||||
|
|
||||||
async def get_status(self) -> MediaStatus:
|
async def get_status(self) -> MediaStatus:
|
||||||
"""Get current media playback status."""
|
"""Get current media playback status."""
|
||||||
status = MediaStatus()
|
status = MediaStatus()
|
||||||
|
|||||||
2505
media_server/static/index.html
Normal file
2505
media_server/static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
111
media_server/static/locales/en.json
Normal file
111
media_server/static/locales/en.json
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"app.title": "Media Server",
|
||||||
|
"auth.message": "Enter your API token to connect to the media server.",
|
||||||
|
"auth.placeholder": "Enter API Token",
|
||||||
|
"auth.connect": "Connect",
|
||||||
|
"auth.help": "To get your token, run:",
|
||||||
|
"auth.logout": "Logout",
|
||||||
|
"auth.logout.title": "Clear saved token",
|
||||||
|
"auth.invalid": "Invalid token. Please try again.",
|
||||||
|
"auth.cleared": "Token cleared. Please enter a new token.",
|
||||||
|
"auth.required": "Please enter a token",
|
||||||
|
"player.theme": "Toggle theme",
|
||||||
|
"player.locale": "Change language",
|
||||||
|
"player.previous": "Previous",
|
||||||
|
"player.play": "Play/Pause",
|
||||||
|
"player.next": "Next",
|
||||||
|
"player.mute": "Mute",
|
||||||
|
"player.status.connected": "Connected",
|
||||||
|
"player.status.disconnected": "Disconnected",
|
||||||
|
"player.no_media": "No media playing",
|
||||||
|
"player.source": "Source:",
|
||||||
|
"player.unknown_source": "Unknown",
|
||||||
|
"state.playing": "Playing",
|
||||||
|
"state.paused": "Paused",
|
||||||
|
"state.stopped": "Stopped",
|
||||||
|
"state.idle": "Idle",
|
||||||
|
"scripts.quick_actions": "Quick Actions",
|
||||||
|
"scripts.no_scripts": "No scripts configured",
|
||||||
|
"scripts.management": "Script Management",
|
||||||
|
"scripts.add": "Add",
|
||||||
|
"scripts.table.name": "Name",
|
||||||
|
"scripts.table.label": "Label",
|
||||||
|
"scripts.table.command": "Command",
|
||||||
|
"scripts.table.timeout": "Timeout",
|
||||||
|
"scripts.table.actions": "Actions",
|
||||||
|
"scripts.empty": "No scripts configured. Click 'Add' to create one.",
|
||||||
|
"scripts.dialog.add": "Add Script",
|
||||||
|
"scripts.dialog.edit": "Edit Script",
|
||||||
|
"scripts.field.name": "Script Name *",
|
||||||
|
"scripts.field.label": "Label",
|
||||||
|
"scripts.field.command": "Command *",
|
||||||
|
"scripts.field.description": "Description",
|
||||||
|
"scripts.field.icon": "Icon (MDI)",
|
||||||
|
"scripts.field.timeout": "Timeout (seconds)",
|
||||||
|
"scripts.placeholder.name": "Only letters, numbers, and underscores allowed",
|
||||||
|
"scripts.placeholder.label": "Human-readable name",
|
||||||
|
"scripts.placeholder.command": "e.g., shutdown /s /t 0",
|
||||||
|
"scripts.placeholder.description": "What does this script do?",
|
||||||
|
"scripts.placeholder.icon": "e.g., mdi:power",
|
||||||
|
"scripts.button.cancel": "Cancel",
|
||||||
|
"scripts.button.save": "Save",
|
||||||
|
"scripts.button.edit": "Edit",
|
||||||
|
"scripts.button.delete": "Delete",
|
||||||
|
"scripts.msg.executed": "{name} executed successfully",
|
||||||
|
"scripts.msg.execute_failed": "Failed to execute {name}",
|
||||||
|
"scripts.msg.execute_error": "Error executing {name}",
|
||||||
|
"scripts.msg.created": "Script created successfully",
|
||||||
|
"scripts.msg.updated": "Script updated successfully",
|
||||||
|
"scripts.msg.create_failed": "Failed to create script",
|
||||||
|
"scripts.msg.update_failed": "Failed to update script",
|
||||||
|
"scripts.msg.deleted": "Script deleted successfully",
|
||||||
|
"scripts.msg.delete_failed": "Failed to delete script",
|
||||||
|
"scripts.msg.not_found": "Script not found",
|
||||||
|
"scripts.msg.load_failed": "Failed to load script details",
|
||||||
|
"scripts.msg.list_failed": "Failed to load scripts",
|
||||||
|
"scripts.confirm.delete": "Are you sure you want to delete the script \"{name}\"?",
|
||||||
|
"scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||||
|
"callbacks.management": "Callback Management",
|
||||||
|
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)",
|
||||||
|
"callbacks.add": "Add",
|
||||||
|
"callbacks.table.event": "Event",
|
||||||
|
"callbacks.table.command": "Command",
|
||||||
|
"callbacks.table.timeout": "Timeout",
|
||||||
|
"callbacks.table.actions": "Actions",
|
||||||
|
"callbacks.empty": "No callbacks configured. Click 'Add' to create one.",
|
||||||
|
"callbacks.dialog.add": "Add Callback",
|
||||||
|
"callbacks.dialog.edit": "Edit Callback",
|
||||||
|
"callbacks.field.event": "Event *",
|
||||||
|
"callbacks.field.command": "Command *",
|
||||||
|
"callbacks.field.timeout": "Timeout (seconds)",
|
||||||
|
"callbacks.field.workdir": "Working Directory",
|
||||||
|
"callbacks.placeholder.event": "Select event...",
|
||||||
|
"callbacks.placeholder.command": "e.g., shutdown /s /t 0",
|
||||||
|
"callbacks.placeholder.workdir": "Optional",
|
||||||
|
"callbacks.button.cancel": "Cancel",
|
||||||
|
"callbacks.button.save": "Save",
|
||||||
|
"callbacks.button.edit": "Edit",
|
||||||
|
"callbacks.button.delete": "Delete",
|
||||||
|
"callbacks.event.on_play": "on_play - After play succeeds",
|
||||||
|
"callbacks.event.on_pause": "on_pause - After pause succeeds",
|
||||||
|
"callbacks.event.on_stop": "on_stop - After stop succeeds",
|
||||||
|
"callbacks.event.on_next": "on_next - After next track succeeds",
|
||||||
|
"callbacks.event.on_previous": "on_previous - After previous track succeeds",
|
||||||
|
"callbacks.event.on_volume": "on_volume - After volume change",
|
||||||
|
"callbacks.event.on_mute": "on_mute - After mute toggle",
|
||||||
|
"callbacks.event.on_seek": "on_seek - After seek succeeds",
|
||||||
|
"callbacks.event.on_turn_on": "on_turn_on - Callback-only action",
|
||||||
|
"callbacks.event.on_turn_off": "on_turn_off - Callback-only action",
|
||||||
|
"callbacks.event.on_toggle": "on_toggle - Callback-only action",
|
||||||
|
"callbacks.msg.created": "Callback created successfully",
|
||||||
|
"callbacks.msg.updated": "Callback updated successfully",
|
||||||
|
"callbacks.msg.create_failed": "Failed to create callback",
|
||||||
|
"callbacks.msg.update_failed": "Failed to update callback",
|
||||||
|
"callbacks.msg.deleted": "Callback deleted successfully",
|
||||||
|
"callbacks.msg.delete_failed": "Failed to delete callback",
|
||||||
|
"callbacks.msg.not_found": "Callback not found",
|
||||||
|
"callbacks.msg.load_failed": "Failed to load callback details",
|
||||||
|
"callbacks.msg.list_failed": "Failed to load callbacks",
|
||||||
|
"callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?",
|
||||||
|
"callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?"
|
||||||
|
}
|
||||||
111
media_server/static/locales/ru.json
Normal file
111
media_server/static/locales/ru.json
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"app.title": "Медиа Сервер",
|
||||||
|
"auth.message": "Введите API токен для подключения к медиа серверу.",
|
||||||
|
"auth.placeholder": "Введите API токен",
|
||||||
|
"auth.connect": "Подключиться",
|
||||||
|
"auth.help": "Чтобы получить токен, выполните:",
|
||||||
|
"auth.logout": "Выйти",
|
||||||
|
"auth.logout.title": "Очистить сохраненный токен",
|
||||||
|
"auth.invalid": "Неверный токен. Пожалуйста, попробуйте снова.",
|
||||||
|
"auth.cleared": "Токен очищен. Пожалуйста, введите новый токен.",
|
||||||
|
"auth.required": "Пожалуйста, введите токен",
|
||||||
|
"player.theme": "Переключить тему",
|
||||||
|
"player.locale": "Изменить язык",
|
||||||
|
"player.previous": "Предыдущий",
|
||||||
|
"player.play": "Воспроизвести/Пауза",
|
||||||
|
"player.next": "Следующий",
|
||||||
|
"player.mute": "Без звука",
|
||||||
|
"player.status.connected": "Подключено",
|
||||||
|
"player.status.disconnected": "Отключено",
|
||||||
|
"player.no_media": "Медиа не воспроизводится",
|
||||||
|
"player.source": "Источник:",
|
||||||
|
"player.unknown_source": "Неизвестно",
|
||||||
|
"state.playing": "Воспроизведение",
|
||||||
|
"state.paused": "Пауза",
|
||||||
|
"state.stopped": "Остановлено",
|
||||||
|
"state.idle": "Ожидание",
|
||||||
|
"scripts.quick_actions": "Быстрые Действия",
|
||||||
|
"scripts.no_scripts": "Скрипты не настроены",
|
||||||
|
"scripts.management": "Управление Скриптами",
|
||||||
|
"scripts.add": "Добавить",
|
||||||
|
"scripts.table.name": "Имя",
|
||||||
|
"scripts.table.label": "Метка",
|
||||||
|
"scripts.table.command": "Команда",
|
||||||
|
"scripts.table.timeout": "Таймаут",
|
||||||
|
"scripts.table.actions": "Действия",
|
||||||
|
"scripts.empty": "Скрипты не настроены. Нажмите 'Добавить' для создания.",
|
||||||
|
"scripts.dialog.add": "Добавить Скрипт",
|
||||||
|
"scripts.dialog.edit": "Редактировать Скрипт",
|
||||||
|
"scripts.field.name": "Имя Скрипта *",
|
||||||
|
"scripts.field.label": "Метка",
|
||||||
|
"scripts.field.command": "Команда *",
|
||||||
|
"scripts.field.description": "Описание",
|
||||||
|
"scripts.field.icon": "Иконка (MDI)",
|
||||||
|
"scripts.field.timeout": "Таймаут (секунды)",
|
||||||
|
"scripts.placeholder.name": "Только буквы, цифры и подчеркивания",
|
||||||
|
"scripts.placeholder.label": "Человеко-читаемое имя",
|
||||||
|
"scripts.placeholder.command": "например, shutdown /s /t 0",
|
||||||
|
"scripts.placeholder.description": "Что делает этот скрипт?",
|
||||||
|
"scripts.placeholder.icon": "например, mdi:power",
|
||||||
|
"scripts.button.cancel": "Отмена",
|
||||||
|
"scripts.button.save": "Сохранить",
|
||||||
|
"scripts.button.edit": "Редактировать",
|
||||||
|
"scripts.button.delete": "Удалить",
|
||||||
|
"scripts.msg.executed": "{name} выполнен успешно",
|
||||||
|
"scripts.msg.execute_failed": "Не удалось выполнить {name}",
|
||||||
|
"scripts.msg.execute_error": "Ошибка выполнения {name}",
|
||||||
|
"scripts.msg.created": "Скрипт создан успешно",
|
||||||
|
"scripts.msg.updated": "Скрипт обновлен успешно",
|
||||||
|
"scripts.msg.create_failed": "Не удалось создать скрипт",
|
||||||
|
"scripts.msg.update_failed": "Не удалось обновить скрипт",
|
||||||
|
"scripts.msg.deleted": "Скрипт удален успешно",
|
||||||
|
"scripts.msg.delete_failed": "Не удалось удалить скрипт",
|
||||||
|
"scripts.msg.not_found": "Скрипт не найден",
|
||||||
|
"scripts.msg.load_failed": "Не удалось загрузить данные скрипта",
|
||||||
|
"scripts.msg.list_failed": "Не удалось загрузить скрипты",
|
||||||
|
"scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?",
|
||||||
|
"scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||||
|
"callbacks.management": "Управление Обратными Вызовами",
|
||||||
|
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
|
||||||
|
"callbacks.add": "Добавить",
|
||||||
|
"callbacks.table.event": "Событие",
|
||||||
|
"callbacks.table.command": "Команда",
|
||||||
|
"callbacks.table.timeout": "Таймаут",
|
||||||
|
"callbacks.table.actions": "Действия",
|
||||||
|
"callbacks.empty": "Обратные вызовы не настроены. Нажмите 'Добавить' для создания.",
|
||||||
|
"callbacks.dialog.add": "Добавить Обратный Вызов",
|
||||||
|
"callbacks.dialog.edit": "Редактировать Обратный Вызов",
|
||||||
|
"callbacks.field.event": "Событие *",
|
||||||
|
"callbacks.field.command": "Команда *",
|
||||||
|
"callbacks.field.timeout": "Таймаут (секунды)",
|
||||||
|
"callbacks.field.workdir": "Рабочая Директория",
|
||||||
|
"callbacks.placeholder.event": "Выберите событие...",
|
||||||
|
"callbacks.placeholder.command": "например, shutdown /s /t 0",
|
||||||
|
"callbacks.placeholder.workdir": "Опционально",
|
||||||
|
"callbacks.button.cancel": "Отмена",
|
||||||
|
"callbacks.button.save": "Сохранить",
|
||||||
|
"callbacks.button.edit": "Редактировать",
|
||||||
|
"callbacks.button.delete": "Удалить",
|
||||||
|
"callbacks.event.on_play": "on_play - После успешного воспроизведения",
|
||||||
|
"callbacks.event.on_pause": "on_pause - После успешной паузы",
|
||||||
|
"callbacks.event.on_stop": "on_stop - После успешной остановки",
|
||||||
|
"callbacks.event.on_next": "on_next - После успешного перехода к следующему",
|
||||||
|
"callbacks.event.on_previous": "on_previous - После успешного перехода к предыдущему",
|
||||||
|
"callbacks.event.on_volume": "on_volume - После изменения громкости",
|
||||||
|
"callbacks.event.on_mute": "on_mute - После переключения звука",
|
||||||
|
"callbacks.event.on_seek": "on_seek - После успешной перемотки",
|
||||||
|
"callbacks.event.on_turn_on": "on_turn_on - Действие только для обратных вызовов",
|
||||||
|
"callbacks.event.on_turn_off": "on_turn_off - Действие только для обратных вызовов",
|
||||||
|
"callbacks.event.on_toggle": "on_toggle - Действие только для обратных вызовов",
|
||||||
|
"callbacks.msg.created": "Обратный вызов создан успешно",
|
||||||
|
"callbacks.msg.updated": "Обратный вызов обновлен успешно",
|
||||||
|
"callbacks.msg.create_failed": "Не удалось создать обратный вызов",
|
||||||
|
"callbacks.msg.update_failed": "Не удалось обновить обратный вызов",
|
||||||
|
"callbacks.msg.deleted": "Обратный вызов удален успешно",
|
||||||
|
"callbacks.msg.delete_failed": "Не удалось удалить обратный вызов",
|
||||||
|
"callbacks.msg.not_found": "Обратный вызов не найден",
|
||||||
|
"callbacks.msg.load_failed": "Не удалось загрузить данные обратного вызова",
|
||||||
|
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
|
||||||
|
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
|
||||||
|
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?"
|
||||||
|
}
|
||||||
24
scripts/restart-server.bat
Normal file
24
scripts/restart-server.bat
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
REM Media Server Restart Script
|
||||||
|
REM This script restarts the media server
|
||||||
|
|
||||||
|
echo Restarting Media Server...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Stop the server first
|
||||||
|
echo [1/2] Stopping server...
|
||||||
|
call "%~dp0\stop-server.bat"
|
||||||
|
|
||||||
|
REM Wait a moment
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
|
||||||
|
REM Change to parent directory (media-server root)
|
||||||
|
cd /d "%~dp0\.."
|
||||||
|
|
||||||
|
REM Start the server
|
||||||
|
echo.
|
||||||
|
echo [2/2] Starting server...
|
||||||
|
python -m media_server.main
|
||||||
|
|
||||||
|
REM If the server exits, pause to show any error messages
|
||||||
|
pause
|
||||||
7
scripts/start-server-background.vbs
Normal file
7
scripts/start-server-background.vbs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Set WshShell = CreateObject("WScript.Shell")
|
||||||
|
Set FSO = CreateObject("Scripting.FileSystemObject")
|
||||||
|
' Get parent folder of scripts folder (media-server root)
|
||||||
|
WshShell.CurrentDirectory = FSO.GetParentFolderName(FSO.GetParentFolderName(WScript.ScriptFullName))
|
||||||
|
WshShell.Run "python -m media_server.main", 0, False
|
||||||
|
Set FSO = Nothing
|
||||||
|
Set WshShell = Nothing
|
||||||
15
scripts/start-server.bat
Normal file
15
scripts/start-server.bat
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@echo off
|
||||||
|
REM Media Server Startup Script
|
||||||
|
REM This script starts the media server
|
||||||
|
|
||||||
|
echo Starting Media Server...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Change to the media-server directory (parent of scripts folder)
|
||||||
|
cd /d "%~dp0\.."
|
||||||
|
|
||||||
|
REM Start the media server
|
||||||
|
python -m media_server.main
|
||||||
|
|
||||||
|
REM If the server exits, pause to show any error messages
|
||||||
|
pause
|
||||||
19
scripts/stop-server.bat
Normal file
19
scripts/stop-server.bat
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
REM Media Server Stop Script
|
||||||
|
REM This script stops the running media server
|
||||||
|
|
||||||
|
echo Stopping Media Server...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Find and kill Python processes running media_server.main
|
||||||
|
for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| findstr /B "PID:"') do (
|
||||||
|
wmic process where "ProcessId=%%i" get CommandLine 2>nul | findstr /C:"media_server.main" >nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
taskkill /PID %%i /F
|
||||||
|
echo Media server process (PID %%i) terminated.
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Done! Media server stopped.
|
||||||
|
pause
|
||||||
Reference in New Issue
Block a user