Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be48318212 | |||
| 0eca8292cb | |||
| 3cfc437599 | |||
| a20812ec29 | |||
| 652f10fc4c | |||
| 3846610042 | |||
| 92d6709d58 | |||
| 9404b37f05 | |||
| 73a6f387e1 | |||
| b11edc25b9 | |||
| 3d01d98da0 | |||
| 4112367175 | |||
| 00d313daa1 | |||
| 0691e3d338 | |||
| 8a8f00ff31 | |||
| 397d38ac12 | |||
| adf2d936da | |||
| 99dbbb1019 | |||
| 6f6a4e4aec | |||
| a568608ec3 | |||
| 03a1b30cd8 | |||
| ef1935c5cf | |||
| 7ee0a60e8d | |||
| 7f28145644 | |||
| 80d4dbccf3 | |||
| caf24db494 | |||
| babdb61791 | |||
| 65b513ca17 | |||
| 84b985e6df | |||
| d1ec27cb7b | |||
| 13df69adb4 | |||
| 4c13322936 | |||
| 5f474d6c9f | |||
| 98a33bca54 | |||
| 8db40d3ee9 | |||
| f275240e59 | |||
| e16674c658 | |||
| 32b058c5fb | |||
| c5f8c7a092 | |||
| 8d15a2a54b | |||
| 1cb83eac1c | |||
| 62c42f70d1 | |||
| eb2aed40c1 | |||
| 7c631d09f6 | |||
| d5ec5c611f | |||
| 29e0618b9f | |||
| 4f8f59dc89 | |||
| 40c2c11c85 | |||
| 0470a17a0c | |||
| 4635caca98 | |||
| 957a177b72 | |||
| 8077181dce | |||
| 9bbb8e1bd7 | |||
| a0af855846 | |||
| d7c5994e56 | |||
| 71a0a6e6d1 | |||
| 5342cffac7 | |||
| a0d138bb93 | |||
| 1a1cfbaafb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ logs/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Thumbnail cache
|
||||
.cache/
|
||||
|
||||
88
CLAUDE.md
88
CLAUDE.md
@@ -26,6 +26,57 @@ To remove the scheduled task:
|
||||
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.
|
||||
|
||||
**CRITICAL** Always check acccessibility of WebUI after server restart to ensure that server has started without issues
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `config.example.yaml` to `config.yaml` and customize.
|
||||
@@ -34,6 +85,43 @@ The API token is generated on first run and displayed in the console output.
|
||||
|
||||
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
|
||||
|
||||
Version is tracked in two files that must be kept in sync:
|
||||
|
||||
365
README.md
365
README.md
@@ -4,13 +4,224 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
|
||||
|
||||
## Features
|
||||
|
||||
- **Built-in Web UI** for real-time media control and monitoring
|
||||
- **Installable PWA** - Add to home screen on mobile for a native app experience
|
||||
- **Audio Visualizer** - Real-time spectrum analyzer with beat-reactive album art effects
|
||||
- **Media Browser** - Browse and play media files from configured folders
|
||||
- **Display Control** - Monitor brightness and power management
|
||||
- **Quick Actions & Scripts** - Execute custom scripts with one click
|
||||
- **Callbacks** - Trigger commands on media events (play, pause, volume, etc.)
|
||||
- Control any media player via system-wide media transport controls
|
||||
- Play/Pause/Stop/Next/Previous track
|
||||
- Volume control and mute
|
||||
- Seek within tracks
|
||||
- Get current track info (title, artist, album, artwork)
|
||||
- Token-based authentication
|
||||
- Cross-platform support
|
||||
- WebSocket support for real-time updates
|
||||
- Token-based authentication with multi-token support
|
||||
- Dark/light theme with customizable accent colors
|
||||
- Multi-language support (English, Russian)
|
||||
- Cross-platform support (Windows, Linux, macOS, Android)
|
||||
|
||||
## 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 glow effect and automatic updates
|
||||
- **Vinyl record mode** - Album art displayed as a spinning vinyl disc with grooves and center spindle
|
||||
- **Playback controls** - Play, pause, next, previous
|
||||
- **Volume control** with mute toggle
|
||||
- **Seekable progress bar** - Click to jump to any position
|
||||
- **Mini player** - Sticky compact player that appears when scrolling away from the main player
|
||||
- **Connection status indicator** - Know when you're connected
|
||||
- **Token authentication** - Saved in browser localStorage
|
||||
- **Audio spectrum visualizer** - Real-time frequency bars with beat-reactive album art scaling and glow (on-demand WASAPI loopback capture)
|
||||
- **Display control** - Monitor brightness adjustment and power on/off
|
||||
- **Installable PWA** - Add to home screen on mobile/desktop for standalone app experience with safe area support for notched phones
|
||||
- **Responsive design** - Works on desktop, tablet, and mobile
|
||||
- **Dark and light themes** - Toggle between dark and light modes with dynamic status bar theming
|
||||
- **Accent color picker** - Choose from 9 preset accent colors or pick a custom color
|
||||
- **Tab-based navigation** - Player, Display, Browser, Quick Actions, and Settings tabs
|
||||
- **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!
|
||||
|
||||
### Installing as a PWA
|
||||
|
||||
The Web UI can be installed as a Progressive Web App for a native app-like experience:
|
||||
|
||||
1. Open the Web UI in Chrome/Edge on your phone or desktop
|
||||
2. Tap the **Install** icon in the address bar (or "Add to Home Screen" on mobile)
|
||||
3. The app launches in standalone mode — no browser chrome, with proper safe area handling for notched phones
|
||||
|
||||
### Audio Visualizer
|
||||
|
||||
The Web UI includes a real-time audio spectrum visualizer that captures system audio output:
|
||||
|
||||
- **On-demand capture** - Audio capture starts only when a client enables the visualizer, and stops when the last client disconnects
|
||||
- **Beat-reactive effects** - Album art pulses and glows in response to bass frequencies
|
||||
- **Configurable device** - Select which audio output device to capture in Settings
|
||||
|
||||
Requires `soundcard` and `numpy` Python packages. Enable in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
visualizer_enabled: true
|
||||
# visualizer_device: "Speakers" # optional: specific device name
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
## Media Browser
|
||||
|
||||
The Media Browser feature allows you to browse and play media files from configured folders directly through the Web UI.
|
||||
|
||||

|
||||
|
||||
### Browser Features
|
||||
|
||||
- **Folder Configuration** - Mount multiple media folders (music/video directories)
|
||||
- **Recursive Navigation** - Browse through folder hierarchies with breadcrumb navigation
|
||||
- **Multiple View Modes** - Grid, compact grid, and list views with toggle buttons
|
||||
- **Thumbnail Display** - Automatically generated thumbnails from album art (lazy-loaded)
|
||||
- **Metadata Extraction** - View title, artist, album, duration, bitrate, file size, and more
|
||||
- **Remote Playback** - Play files on the PC running the media server (not in the browser)
|
||||
- **Play All** - Play all media files in the current folder
|
||||
- **File Download** - Download individual media files directly from the browser
|
||||
- **Search & Filter** - Real-time search across files in the current folder
|
||||
- **Pagination** - Navigate large folders with configurable page sizes (25, 50, 100, 200, 500)
|
||||
- **Last Path Memory** - Automatically returns to your last browsed location
|
||||
|
||||
### Configuration
|
||||
|
||||
Add media folders in your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
# Media folders for browser
|
||||
media_folders:
|
||||
music:
|
||||
path: "C:\\Users\\YourUsername\\Music"
|
||||
label: "My Music"
|
||||
enabled: true
|
||||
videos:
|
||||
path: "C:\\Users\\YourUsername\\Videos"
|
||||
label: "My Videos"
|
||||
enabled: true
|
||||
|
||||
# Thumbnail size: "small" (150x150), "medium" (300x300), or "both"
|
||||
thumbnail_size: "medium"
|
||||
```
|
||||
|
||||
### How Playback Works
|
||||
|
||||
When you play a file from the Media Browser:
|
||||
|
||||
1. The file is opened using the **default system media player** on the PC running the media server
|
||||
2. This is designed for **remote control scenarios** where you browse media from one device (e.g., Home Assistant dashboard, phone) but want audio to play on the PC
|
||||
3. The media player must support the **Windows Media Session API** for playback tracking
|
||||
|
||||
### Media Player Compatibility
|
||||
|
||||
**⚠️ Important Limitation:** Not all media players expose their playback information to the Windows Media Session API. This means some players will open and play the file, but the Media Server UI won't show playback status, track information, or allow remote control.
|
||||
|
||||
**✅ Compatible Players** (work with playback tracking):
|
||||
|
||||
- **VLC Media Player** - Full support
|
||||
- **Groove Music** (Windows 10/11 built-in) - Full support
|
||||
- **Spotify** - Full support (if already running)
|
||||
- **Chrome/Edge/Firefox** - Full support for web players
|
||||
- **foobar2000** - Full support (with proper configuration/plugins)
|
||||
|
||||
**❌ Limited/No Support:**
|
||||
|
||||
- **Windows Media Player Classic** - Opens files but doesn't expose session info
|
||||
- **Windows Media Player** (classic version) - Limited session support
|
||||
|
||||
**Recommendation:** Set **VLC Media Player** or **Groove Music** as your default audio player for the best experience with the Media Browser.
|
||||
|
||||
#### Changing Your Default Media Player (Windows)
|
||||
|
||||
1. Open Windows Settings → Apps → Default apps
|
||||
2. Search for "Music player" or "Video player"
|
||||
3. Select VLC Media Player or Groove Music
|
||||
4. Files opened from Media Browser will now use the selected player
|
||||
|
||||
### API Endpoints
|
||||
|
||||
The Media Browser exposes several REST API endpoints:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|--------------------------|--------|-----------------------------------|
|
||||
| `/api/browser/folders` | GET | List configured media folders |
|
||||
| `/api/browser/browse` | GET | Browse directory contents |
|
||||
| `/api/browser/metadata` | GET | Get media file metadata |
|
||||
| `/api/browser/thumbnail` | GET | Get thumbnail image |
|
||||
| `/api/browser/play` | POST | Open file with default player |
|
||||
|
||||
All endpoints require bearer token authentication.
|
||||
|
||||
### Security Notes
|
||||
|
||||
- **Path Traversal Protection** - All paths are validated to prevent directory traversal attacks
|
||||
- **Folder Restrictions** - Only configured folders are accessible
|
||||
- **Authentication Required** - All endpoints require a valid API token
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -71,13 +282,17 @@ Requires Termux and Termux:API apps from F-Droid.
|
||||
python -m media_server.main
|
||||
```
|
||||
|
||||
4. Test the connection:
|
||||
```bash
|
||||
curl http://localhost:8765/api/health
|
||||
```
|
||||
4. **Open the Web UI** (recommended):
|
||||
- Navigate to `http://localhost:8765/` in your browser
|
||||
- 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
|
||||
# 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
|
||||
```
|
||||
|
||||
@@ -92,11 +307,46 @@ Configuration file locations:
|
||||
```yaml
|
||||
host: 0.0.0.0
|
||||
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
|
||||
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
|
||||
|
||||
All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`):
|
||||
@@ -104,10 +354,11 @@ All settings can be overridden with environment variables (prefix: `MEDIA_SERVER
|
||||
```bash
|
||||
export MEDIA_SERVER_HOST=0.0.0.0
|
||||
export MEDIA_SERVER_PORT=8765
|
||||
export MEDIA_SERVER_API_TOKEN=your-token
|
||||
export MEDIA_SERVER_LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
**Note:** For multi-token configuration, use the config.yaml file. Environment variables only support single-token mode.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Health Check
|
||||
@@ -164,10 +415,15 @@ All control endpoints require authentication and return `{"success": true}` on s
|
||||
| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
|
||||
| `/api/media/mute` | POST | - | Toggle mute |
|
||||
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) |
|
||||
| `/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
|
||||
|
||||
The server supports executing pre-defined scripts via API.
|
||||
The server supports executing pre-defined scripts via API and the Web UI. Scripts and callbacks can be managed directly from the Web UI — add, edit, delete, and execute with real-time output display.
|
||||
|
||||

|
||||
|
||||
#### List Scripts
|
||||
|
||||
@@ -263,6 +519,95 @@ Script configuration options:
|
||||
| `working_dir` | No | Working directory for the command |
|
||||
| `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
|
||||
|
||||
### Windows Task Scheduler (Recommended)
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
# Copy this file to config.yaml and customize as needed.
|
||||
# A secure token will be auto-generated on first run if not specified.
|
||||
|
||||
# API Token (generate a secure random token)
|
||||
api_token: "your-secure-token-here"
|
||||
# API Tokens - Multiple tokens with friendly labels
|
||||
# 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
|
||||
host: "0.0.0.0"
|
||||
@@ -45,3 +49,63 @@ scripts:
|
||||
description: "Restart the PC immediately"
|
||||
timeout: 10
|
||||
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
|
||||
BIN
docs/media-browser.PNG
Normal file
BIN
docs/media-browser.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
BIN
docs/scripts-management.PNG
Normal file
BIN
docs/scripts-management.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/web-ui.PNG
Normal file
BIN
docs/web-ui.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
@@ -1,5 +1,7 @@
|
||||
"""Authentication middleware and utilities."""
|
||||
|
||||
import secrets
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, Request, status
|
||||
@@ -9,6 +11,24 @@ from .config import settings
|
||||
|
||||
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(
|
||||
request: Request,
|
||||
@@ -16,16 +36,19 @@ async def verify_token(
|
||||
) -> str:
|
||||
"""Verify the API token from the Authorization header.
|
||||
|
||||
Args:
|
||||
request: The incoming request
|
||||
credentials: The bearer token credentials
|
||||
Reuses the label from middleware context when already validated.
|
||||
|
||||
Returns:
|
||||
The validated token
|
||||
The token label
|
||||
|
||||
Raises:
|
||||
HTTPException: If the token is missing or invalid
|
||||
"""
|
||||
# Reuse label already set by middleware to avoid redundant O(n) scan
|
||||
existing = token_label_var.get("unknown")
|
||||
if existing != "unknown":
|
||||
return existing
|
||||
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -33,14 +56,16 @@ async def verify_token(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if credentials.credentials != settings.api_token:
|
||||
label = get_token_label(credentials.credentials)
|
||||
if label is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return credentials.credentials
|
||||
token_label_var.set(label)
|
||||
return label
|
||||
|
||||
|
||||
class TokenAuth:
|
||||
@@ -54,7 +79,7 @@ class TokenAuth:
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> 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 self.auto_error:
|
||||
raise HTTPException(
|
||||
@@ -64,7 +89,8 @@ class TokenAuth:
|
||||
)
|
||||
return None
|
||||
|
||||
if credentials.credentials != settings.api_token:
|
||||
label = get_token_label(credentials.credentials)
|
||||
if label is None:
|
||||
if self.auto_error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -73,7 +99,9 @@ class TokenAuth:
|
||||
)
|
||||
return None
|
||||
|
||||
return credentials.credentials
|
||||
# Set label in context for logging
|
||||
token_label_var.set(label)
|
||||
return label
|
||||
|
||||
|
||||
async def verify_token_or_query(
|
||||
@@ -89,23 +117,32 @@ async def verify_token_or_query(
|
||||
token: Token from query parameter
|
||||
|
||||
Returns:
|
||||
The validated token
|
||||
The token label
|
||||
|
||||
Raises:
|
||||
HTTPException: If the token is missing or invalid
|
||||
"""
|
||||
# Reuse label already set by middleware
|
||||
existing = token_label_var.get("unknown")
|
||||
if existing != "unknown":
|
||||
return existing
|
||||
|
||||
label = None
|
||||
|
||||
# Try header first
|
||||
if credentials is not None:
|
||||
if credentials.credentials == settings.api_token:
|
||||
return credentials.credentials
|
||||
label = get_token_label(credentials.credentials)
|
||||
|
||||
# Try query parameter
|
||||
if token is not None:
|
||||
if token == settings.api_token:
|
||||
return token
|
||||
if label is None and token is not None:
|
||||
label = get_token_label(token)
|
||||
|
||||
if label is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing or invalid authentication token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token_label_var.set(label)
|
||||
return label
|
||||
|
||||
@@ -10,6 +10,23 @@ from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class MediaFolderConfig(BaseModel):
|
||||
"""Configuration for a media folder."""
|
||||
|
||||
path: str = Field(..., description="Absolute path to media folder")
|
||||
label: str = Field(..., description="Human-readable display label")
|
||||
enabled: bool = Field(default=True, description="Whether this folder is active")
|
||||
|
||||
|
||||
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):
|
||||
"""Configuration for a custom script."""
|
||||
|
||||
@@ -22,6 +39,15 @@ class ScriptConfig(BaseModel):
|
||||
shell: bool = Field(default=True, description="Run command in shell")
|
||||
|
||||
|
||||
class LinkConfig(BaseModel):
|
||||
"""Configuration for a header quick link."""
|
||||
|
||||
url: str = Field(..., description="URL to open")
|
||||
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
|
||||
label: str = Field(default="", description="Tooltip text")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment or config file."""
|
||||
|
||||
@@ -37,9 +63,9 @@ class Settings(BaseSettings):
|
||||
port: int = Field(default=8765, description="Server port")
|
||||
|
||||
# Authentication
|
||||
api_token: str = Field(
|
||||
default_factory=lambda: secrets.token_urlsafe(32),
|
||||
description="API authentication token",
|
||||
api_tokens: dict[str, str] = Field(
|
||||
default_factory=lambda: {"default": secrets.token_urlsafe(32)},
|
||||
description="Named API tokens for access control (label: token pairs)",
|
||||
)
|
||||
|
||||
# Media controller settings
|
||||
@@ -47,6 +73,12 @@ class Settings(BaseSettings):
|
||||
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
|
||||
log_level: str = Field(default="INFO", description="Logging level")
|
||||
|
||||
@@ -56,6 +88,52 @@ class Settings(BaseSettings):
|
||||
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)",
|
||||
)
|
||||
|
||||
# Media folders for browsing
|
||||
media_folders: dict[str, MediaFolderConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Media folders available for browsing in the media browser",
|
||||
)
|
||||
|
||||
# Thumbnail settings
|
||||
thumbnail_size: str = Field(
|
||||
default="medium",
|
||||
description='Thumbnail size: "small" (150x150), "medium" (300x300), or "both"',
|
||||
)
|
||||
|
||||
# Header quick links
|
||||
links: dict[str, LinkConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Quick links displayed as icons in the header",
|
||||
)
|
||||
|
||||
# Audio visualizer
|
||||
visualizer_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable audio spectrum visualizer (requires soundcard + numpy)",
|
||||
)
|
||||
visualizer_fps: int = Field(
|
||||
default=30,
|
||||
description="Visualizer update rate in frames per second",
|
||||
ge=10,
|
||||
le=60,
|
||||
)
|
||||
visualizer_bins: int = Field(
|
||||
default=32,
|
||||
description="Number of frequency bins for the visualizer",
|
||||
ge=8,
|
||||
le=128,
|
||||
)
|
||||
visualizer_device: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Loopback audio device name for visualizer (None = auto-detect)",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
||||
"""Load settings from a YAML configuration file."""
|
||||
@@ -107,9 +185,14 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
config = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"api_token": secrets.token_urlsafe(32),
|
||||
"api_tokens": {
|
||||
"default": secrets.token_urlsafe(32),
|
||||
},
|
||||
"poll_interval": 1.0,
|
||||
"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": {
|
||||
"example_script": {
|
||||
"command": "echo Hello from Media Server!",
|
||||
|
||||
456
media_server/config_manager.py
Normal file
456
media_server/config_manager.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""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, LinkConfig, MediaFolderConfig, 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")
|
||||
|
||||
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||
"""Add a new media folder to config.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID (must be unique).
|
||||
config: Media folder configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder 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 folder already exists
|
||||
if "media_folders" in data and folder_id in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' already exists")
|
||||
|
||||
# Add folder
|
||||
if "media_folders" not in data:
|
||||
data["media_folders"] = {}
|
||||
data["media_folders"][folder_id] = 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.media_folders[folder_id] = config
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' added to config")
|
||||
|
||||
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||
"""Update an existing media folder.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID.
|
||||
config: New media folder configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder 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 folder exists
|
||||
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
||||
|
||||
# Update folder
|
||||
data["media_folders"][folder_id] = 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.media_folders[folder_id] = config
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' updated in config")
|
||||
|
||||
def delete_media_folder(self, folder_id: str) -> None:
|
||||
"""Delete a media folder from config.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder 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 folder exists
|
||||
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
||||
|
||||
# Delete folder
|
||||
del data["media_folders"][folder_id]
|
||||
|
||||
# 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 folder_id in settings.media_folders:
|
||||
del settings.media_folders[folder_id]
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' deleted from config")
|
||||
|
||||
def add_link(self, name: str, config: LinkConfig) -> None:
|
||||
"""Add a new link to config."""
|
||||
with self._lock:
|
||||
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 {}
|
||||
|
||||
if "links" in data and name in data["links"]:
|
||||
raise ValueError(f"Link '{name}' already exists")
|
||||
|
||||
if "links" not in data:
|
||||
data["links"] = {}
|
||||
data["links"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
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)
|
||||
|
||||
settings.links[name] = config
|
||||
logger.info(f"Link '{name}' added to config")
|
||||
|
||||
def update_link(self, name: str, config: LinkConfig) -> None:
|
||||
"""Update an existing link."""
|
||||
with self._lock:
|
||||
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 {}
|
||||
|
||||
if "links" not in data or name not in data["links"]:
|
||||
raise ValueError(f"Link '{name}' does not exist")
|
||||
|
||||
data["links"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
settings.links[name] = config
|
||||
logger.info(f"Link '{name}' updated in config")
|
||||
|
||||
def delete_link(self, name: str) -> None:
|
||||
"""Delete a link from config."""
|
||||
with self._lock:
|
||||
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 {}
|
||||
|
||||
if "links" not in data or name not in data["links"]:
|
||||
raise ValueError(f"Link '{name}' does not exist")
|
||||
|
||||
del data["links"][name]
|
||||
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
if name in settings.links:
|
||||
del settings.links[name]
|
||||
logger.info(f"Link '{name}' deleted from config")
|
||||
|
||||
|
||||
# Global config manager instance
|
||||
config_manager = ConfigManager()
|
||||
@@ -4,24 +4,42 @@ import argparse
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import __version__
|
||||
from .auth import get_token_label, token_label_var
|
||||
from .config import settings, generate_default_config, get_config_dir
|
||||
from .routes import health_router, media_router, scripts_router
|
||||
from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, links_router, media_router, scripts_router
|
||||
from .services import get_media_controller
|
||||
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():
|
||||
"""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(
|
||||
level=getattr(logging, settings.log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s",
|
||||
handlers=[handler],
|
||||
)
|
||||
|
||||
|
||||
@@ -31,17 +49,48 @@ async def lifespan(app: FastAPI):
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||
logger.info(f"API Token: {settings.api_token[:8]}...")
|
||||
|
||||
# Log all configured tokens
|
||||
for label, token in settings.api_tokens.items():
|
||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||
|
||||
# Start WebSocket status monitor
|
||||
controller = get_media_controller()
|
||||
await ws_manager.start_status_monitor(controller.get_status)
|
||||
logger.info("WebSocket status monitor started")
|
||||
|
||||
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||
analyzer = None
|
||||
if settings.visualizer_enabled:
|
||||
from .services.audio_analyzer import get_audio_analyzer
|
||||
|
||||
analyzer = get_audio_analyzer(
|
||||
num_bins=settings.visualizer_bins,
|
||||
target_fps=settings.visualizer_fps,
|
||||
device_name=settings.visualizer_device,
|
||||
)
|
||||
if analyzer.available:
|
||||
await ws_manager.start_audio_monitor(analyzer)
|
||||
logger.info("Audio visualizer available (capture on-demand)")
|
||||
else:
|
||||
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
|
||||
|
||||
yield
|
||||
|
||||
# Stop audio visualizer
|
||||
await ws_manager.stop_audio_monitor()
|
||||
if analyzer and analyzer.running:
|
||||
analyzer.stop()
|
||||
|
||||
# Stop WebSocket status monitor
|
||||
await ws_manager.stop_status_monitor()
|
||||
|
||||
# Clean up platform-specific resources
|
||||
import platform as _platform
|
||||
if _platform.system() == "Windows":
|
||||
from .services.windows_media import shutdown_executor
|
||||
shutdown_executor()
|
||||
|
||||
logger.info("Media Server shutting down")
|
||||
|
||||
|
||||
@@ -54,20 +103,73 @@ def create_app() -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Compress responses > 1KB
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# Add CORS middleware for cross-origin requests
|
||||
# Token auth is via Authorization header, not cookies, so credentials are not needed
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
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
|
||||
app.include_router(audio_router)
|
||||
app.include_router(browser_router)
|
||||
app.include_router(callbacks_router)
|
||||
app.include_router(display_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(links_router)
|
||||
app.include_router(media_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.get("/sw.js", include_in_schema=False)
|
||||
async def serve_service_worker():
|
||||
"""Serve service worker from root scope for PWA installability."""
|
||||
return FileResponse(
|
||||
static_dir / "sw.js",
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -108,8 +210,10 @@ def main():
|
||||
return
|
||||
|
||||
if args.show_token:
|
||||
print(f"API Token: {settings.api_token}")
|
||||
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
|
||||
|
||||
uvicorn.run(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""API route modules."""
|
||||
|
||||
from .audio import router as audio_router
|
||||
from .browser import router as browser_router
|
||||
from .callbacks import router as callbacks_router
|
||||
from .display import router as display_router
|
||||
from .health import router as health_router
|
||||
from .links import router as links_router
|
||||
from .media import router as media_router
|
||||
from .scripts import router as scripts_router
|
||||
|
||||
__all__ = ["health_router", "media_router", "scripts_router"]
|
||||
__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "links_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
|
||||
567
media_server/routes/browser.py
Normal file
567
media_server/routes/browser.py
Normal file
@@ -0,0 +1,567 @@
|
||||
"""Browser API routes for media file browsing."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import verify_token, verify_token_or_query
|
||||
from ..config import MediaFolderConfig, settings
|
||||
from ..config_manager import config_manager
|
||||
from ..services.browser_service import BrowserService
|
||||
from ..services.metadata_service import MetadataService
|
||||
from ..services.thumbnail_service import ThumbnailService
|
||||
from ..services import get_media_controller
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
||||
|
||||
|
||||
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
|
||||
"""Poll until media session registers, then broadcast status update.
|
||||
|
||||
Fires as a background task so the HTTP response returns immediately.
|
||||
"""
|
||||
try:
|
||||
interval = 0.3
|
||||
elapsed = 0.0
|
||||
while elapsed < max_wait:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
status = await controller.get_status()
|
||||
if status.state in ("playing", "paused"):
|
||||
break
|
||||
|
||||
status_dict = status.model_dump()
|
||||
await ws_manager.broadcast({"type": "status", "data": status_dict})
|
||||
logger.info(f"Broadcasted status update after opening: {label}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to broadcast status after opening {label}: {e}")
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class FolderCreateRequest(BaseModel):
|
||||
"""Request model for creating a media folder."""
|
||||
|
||||
folder_id: str = Field(..., description="Unique folder ID")
|
||||
label: str = Field(..., description="Display label")
|
||||
path: str = Field(..., description="Absolute path to media folder")
|
||||
enabled: bool = Field(default=True, description="Whether folder is enabled")
|
||||
|
||||
|
||||
class FolderUpdateRequest(BaseModel):
|
||||
"""Request model for updating a media folder."""
|
||||
|
||||
label: str = Field(..., description="Display label")
|
||||
path: str = Field(..., description="Absolute path to media folder")
|
||||
enabled: bool = Field(default=True, description="Whether folder is enabled")
|
||||
|
||||
|
||||
class PlayRequest(BaseModel):
|
||||
"""Request model for playing a media file."""
|
||||
|
||||
path: str = Field(..., description="Full path to the media file")
|
||||
|
||||
|
||||
class PlayFolderRequest(BaseModel):
|
||||
"""Request model for playing all media files in a folder."""
|
||||
|
||||
folder_id: str = Field(..., description="Media folder ID")
|
||||
path: str = Field(default="", description="Path relative to folder root")
|
||||
|
||||
|
||||
# Folder Management Endpoints
|
||||
@router.get("/folders")
|
||||
async def list_folders(_: str = Depends(verify_token)):
|
||||
"""List all configured media folders.
|
||||
|
||||
Returns:
|
||||
Dictionary of folder configurations.
|
||||
"""
|
||||
folders = {}
|
||||
for folder_id, config in settings.media_folders.items():
|
||||
folders[folder_id] = {
|
||||
"id": folder_id,
|
||||
"label": config.label,
|
||||
"path": config.path,
|
||||
"enabled": config.enabled,
|
||||
}
|
||||
return folders
|
||||
|
||||
|
||||
@router.post("/folders/create")
|
||||
async def create_folder(
|
||||
request: FolderCreateRequest,
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Create a new media folder configuration.
|
||||
|
||||
Args:
|
||||
request: Folder creation request.
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
|
||||
Raises:
|
||||
HTTPException: If folder already exists or validation fails.
|
||||
"""
|
||||
try:
|
||||
# Validate folder_id format (alphanumeric and underscore only)
|
||||
if not request.folder_id.replace("_", "").isalnum():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Folder ID must contain only alphanumeric characters and underscores",
|
||||
)
|
||||
|
||||
# Validate path exists
|
||||
path = Path(request.path)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Path does not exist: {request.path}")
|
||||
if not path.is_dir():
|
||||
raise HTTPException(status_code=400, detail=f"Path is not a directory: {request.path}")
|
||||
|
||||
# Create config
|
||||
config = MediaFolderConfig(
|
||||
path=request.path,
|
||||
label=request.label,
|
||||
enabled=request.enabled,
|
||||
)
|
||||
|
||||
# Add to config manager
|
||||
config_manager.add_media_folder(request.folder_id, config)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Media folder '{request.folder_id}' created successfully",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating media folder: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to create media folder")
|
||||
|
||||
|
||||
@router.put("/folders/update/{folder_id}")
|
||||
async def update_folder(
|
||||
folder_id: str,
|
||||
request: FolderUpdateRequest,
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Update an existing media folder configuration.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the folder to update.
|
||||
request: Folder update request.
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
|
||||
Raises:
|
||||
HTTPException: If folder doesn't exist or validation fails.
|
||||
"""
|
||||
try:
|
||||
# Validate path exists
|
||||
path = Path(request.path)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Path does not exist: {request.path}")
|
||||
if not path.is_dir():
|
||||
raise HTTPException(status_code=400, detail=f"Path is not a directory: {request.path}")
|
||||
|
||||
# Create config
|
||||
config = MediaFolderConfig(
|
||||
path=request.path,
|
||||
label=request.label,
|
||||
enabled=request.enabled,
|
||||
)
|
||||
|
||||
# Update config manager
|
||||
config_manager.update_media_folder(folder_id, config)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Media folder '{folder_id}' updated successfully",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating media folder: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to update media folder")
|
||||
|
||||
|
||||
@router.delete("/folders/delete/{folder_id}")
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Delete a media folder configuration.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the folder to delete.
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
|
||||
Raises:
|
||||
HTTPException: If folder doesn't exist.
|
||||
"""
|
||||
try:
|
||||
config_manager.delete_media_folder(folder_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Media folder '{folder_id}' deleted successfully",
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting media folder: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to delete media folder")
|
||||
|
||||
|
||||
# Browse Endpoints
|
||||
@router.get("/browse")
|
||||
async def browse(
|
||||
folder_id: str = Query(..., description="Media folder ID"),
|
||||
path: str = Query(default="", description="Path relative to folder root"),
|
||||
offset: int = Query(default=0, ge=0, description="Pagination offset"),
|
||||
limit: int = Query(default=100, ge=1, le=1000, description="Pagination limit"),
|
||||
nocache: bool = Query(default=False, description="Bypass directory cache"),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Browse a directory and list files/folders.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the media folder.
|
||||
path: Path to browse (URL-encoded, relative to folder root).
|
||||
offset: Pagination offset.
|
||||
limit: Maximum items to return.
|
||||
|
||||
Returns:
|
||||
Directory listing with items and metadata.
|
||||
|
||||
Raises:
|
||||
HTTPException: If path validation fails or directory not accessible.
|
||||
"""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
|
||||
# Browse directory
|
||||
result = BrowserService.browse_directory(
|
||||
folder_id=folder_id,
|
||||
path=decoded_path,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
nocache=nocache,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except (OSError, PermissionError) as e:
|
||||
# Network share unavailable or access denied
|
||||
logger.warning(f"Folder temporarily unavailable: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Folder is temporarily unavailable. It may be a network share that is not accessible at the moment."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error browsing directory (type: {type(e).__name__}): {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to browse directory")
|
||||
|
||||
|
||||
# Metadata Endpoint
|
||||
@router.get("/metadata")
|
||||
async def get_metadata(
|
||||
path: str = Query(..., description="Full path to media file (URL-encoded)"),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Get metadata for a media file.
|
||||
|
||||
Args:
|
||||
path: Full path to the media file (URL-encoded).
|
||||
|
||||
Returns:
|
||||
Media file metadata.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or metadata extraction fails.
|
||||
"""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
file_path = Path(decoded_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
# Extract metadata in executor (blocking operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
metadata = await loop.run_in_executor(
|
||||
None,
|
||||
MetadataService.extract_metadata,
|
||||
file_path,
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting metadata: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to extract metadata")
|
||||
|
||||
|
||||
# Thumbnail Endpoint
|
||||
@router.get("/thumbnail")
|
||||
async def get_thumbnail(
|
||||
path: str = Query(..., description="Full path to media file (URL-encoded)"),
|
||||
size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Get thumbnail for a media file.
|
||||
|
||||
Args:
|
||||
path: Full path to the media file (URL-encoded).
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
|
||||
Returns:
|
||||
JPEG image bytes.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or thumbnail generation fails.
|
||||
"""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
file_path = Path(decoded_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
# Validate size
|
||||
if size not in ("small", "medium"):
|
||||
size = "medium"
|
||||
|
||||
# Get thumbnail
|
||||
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
|
||||
|
||||
if thumbnail_data is None:
|
||||
return Response(status_code=204)
|
||||
|
||||
# Calculate ETag (hash of path + mtime)
|
||||
import hashlib
|
||||
stat = file_path.stat()
|
||||
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
|
||||
etag = hashlib.md5(etag_data).hexdigest()
|
||||
|
||||
# Return image with caching headers
|
||||
return Response(
|
||||
content=thumbnail_data,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"ETag": f'"{etag}"',
|
||||
"Cache-Control": "public, max-age=86400", # 24 hours
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnail: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
|
||||
|
||||
|
||||
# Playback Endpoint
|
||||
@router.post("/play")
|
||||
async def play_file(
|
||||
request: PlayRequest,
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Open a media file with the default system player.
|
||||
|
||||
Args:
|
||||
request: Play request with file path.
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or playback fails.
|
||||
"""
|
||||
try:
|
||||
file_path = Path(request.path)
|
||||
|
||||
# Validate file exists
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
# Validate file is a media file
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Get media controller and open file
|
||||
controller = get_media_controller()
|
||||
success = await controller.open_file(str(file_path))
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to open file")
|
||||
|
||||
# Poll until player registers with media session API (up to 2s)
|
||||
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Playing {file_path.name}",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error playing file: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to play file")
|
||||
|
||||
|
||||
# Play Folder Endpoint (M3U playlist)
|
||||
@router.post("/play-folder")
|
||||
async def play_folder(
|
||||
request: PlayFolderRequest,
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Play all media files in a folder by generating an M3U playlist.
|
||||
|
||||
Args:
|
||||
request: Play folder request with folder_id and path.
|
||||
|
||||
Returns:
|
||||
Success message with file count.
|
||||
|
||||
Raises:
|
||||
HTTPException: If folder not found or playback fails.
|
||||
"""
|
||||
try:
|
||||
decoded_path = unquote(request.path)
|
||||
full_path = BrowserService.validate_path(request.folder_id, decoded_path)
|
||||
|
||||
if not full_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||
|
||||
# Collect all media files sorted by name
|
||||
media_files = sorted(
|
||||
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)],
|
||||
key=lambda f: f.name.lower(),
|
||||
)
|
||||
|
||||
if not media_files:
|
||||
raise HTTPException(status_code=404, detail="No media files found in this folder")
|
||||
|
||||
# Generate M3U playlist with absolute paths and EXTINF entries
|
||||
# Written to local temp dir to avoid extra SMB file handle on network shares
|
||||
# Uses utf-8-sig (BOM) so players detect encoding properly
|
||||
lines = ["#EXTM3U"]
|
||||
for f in media_files:
|
||||
lines.append(f"#EXTINF:-1,{f.stem}")
|
||||
lines.append(str(f))
|
||||
m3u_content = "\r\n".join(lines) + "\r\n"
|
||||
|
||||
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u"
|
||||
playlist_path.write_text(m3u_content, encoding="utf-8-sig")
|
||||
|
||||
# Open playlist with default player
|
||||
controller = get_media_controller()
|
||||
success = await controller.open_file(playlist_path)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to open playlist")
|
||||
|
||||
# Poll until player registers with media session API (up to 2s)
|
||||
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)"))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Playing {len(media_files)} files",
|
||||
"count": len(media_files),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error playing folder: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to play folder")
|
||||
|
||||
|
||||
# Download Endpoint
|
||||
@router.get("/download")
|
||||
async def download_file(
|
||||
folder_id: str = Query(..., description="Media folder ID"),
|
||||
path: str = Query(..., description="File path relative to folder root (URL-encoded)"),
|
||||
_: str = Depends(verify_token_or_query),
|
||||
):
|
||||
"""Download a media file.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the media folder.
|
||||
path: Path to the file (URL-encoded, relative to folder root).
|
||||
|
||||
Returns:
|
||||
File download response.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or not a media file.
|
||||
"""
|
||||
try:
|
||||
decoded_path = unquote(path)
|
||||
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=file_path.name,
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading file: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to download file")
|
||||
343
media_server/routes/callbacks.py
Normal file
343
media_server/routes/callbacks.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""Callback management API endpoints."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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__)
|
||||
|
||||
# Dedicated executor for callback/subprocess execution
|
||||
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
|
||||
|
||||
|
||||
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 dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
_callback_executor,
|
||||
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}
|
||||
59
media_server/routes/display.py
Normal file
59
media_server/routes/display.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Display brightness and power control API endpoints."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..services.display_service import (
|
||||
get_brightness,
|
||||
list_monitors,
|
||||
set_brightness,
|
||||
set_power,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/display", tags=["display"])
|
||||
|
||||
|
||||
class BrightnessRequest(BaseModel):
|
||||
brightness: int = Field(ge=0, le=100)
|
||||
|
||||
|
||||
class PowerRequest(BaseModel):
|
||||
on: bool
|
||||
|
||||
|
||||
@router.get("/monitors")
|
||||
async def get_monitors(
|
||||
refresh: bool = False, _: str = Depends(verify_token)
|
||||
) -> list[dict]:
|
||||
"""List all connected monitors with brightness and power info."""
|
||||
monitors = list_monitors(force_refresh=refresh)
|
||||
logger.debug("Found %d monitors", len(monitors))
|
||||
return [m.to_dict() for m in monitors]
|
||||
|
||||
|
||||
@router.post("/brightness/{monitor_id}")
|
||||
async def set_monitor_brightness(
|
||||
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Set brightness for a specific monitor."""
|
||||
success = set_brightness(monitor_id, request.brightness)
|
||||
if success:
|
||||
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/power/{monitor_id}")
|
||||
async def set_monitor_power(
|
||||
monitor_id: int, request: PowerRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Turn a monitor on or off."""
|
||||
action = "on" if request.on else "off"
|
||||
success = set_power(monitor_id, request.on)
|
||||
if success:
|
||||
logger.info("Set monitor %d power %s", monitor_id, action)
|
||||
return {"success": success}
|
||||
188
media_server/routes/links.py
Normal file
188
media_server/routes/links.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Header quick links management API endpoints."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..config import LinkConfig, settings
|
||||
from ..config_manager import config_manager
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
router = APIRouter(prefix="/api/links", tags=["links"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LinkInfo(BaseModel):
|
||||
"""Information about a configured link."""
|
||||
|
||||
name: str
|
||||
url: str
|
||||
icon: str
|
||||
label: str
|
||||
description: str
|
||||
|
||||
|
||||
class LinkCreateRequest(BaseModel):
|
||||
"""Request model for creating or updating a link."""
|
||||
|
||||
url: str = Field(..., description="URL to open", min_length=1)
|
||||
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
|
||||
label: str = Field(default="", description="Tooltip text")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
|
||||
|
||||
def _validate_link_name(name: str) -> None:
|
||||
"""Validate link name.
|
||||
|
||||
Args:
|
||||
name: Link name to validate.
|
||||
|
||||
Raises:
|
||||
HTTPException: If name is invalid.
|
||||
"""
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Link name must contain only letters, numbers, and underscores",
|
||||
)
|
||||
if len(name) > 64:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Link name must be 64 characters or less",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_links(_: str = Depends(verify_token)) -> list[LinkInfo]:
|
||||
"""List all configured links.
|
||||
|
||||
Returns:
|
||||
List of configured links.
|
||||
"""
|
||||
return [
|
||||
LinkInfo(
|
||||
name=name,
|
||||
url=config.url,
|
||||
icon=config.icon,
|
||||
label=config.label,
|
||||
description=config.description,
|
||||
)
|
||||
for name, config in settings.links.items()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create/{link_name}")
|
||||
async def create_link(
|
||||
link_name: str,
|
||||
request: LinkCreateRequest,
|
||||
_: str = Depends(verify_token),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new link.
|
||||
|
||||
Args:
|
||||
link_name: Link name (alphanumeric and underscores only).
|
||||
request: Link configuration.
|
||||
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name in settings.links:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Link '{link_name}' already exists. Use PUT /api/links/update/{link_name} to update it.",
|
||||
)
|
||||
|
||||
link_config = LinkConfig(**request.model_dump())
|
||||
|
||||
try:
|
||||
config_manager.add_link(link_name, link_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add link '{link_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to add link: {str(e)}",
|
||||
)
|
||||
|
||||
await ws_manager.broadcast_links_changed()
|
||||
logger.info(f"Link '{link_name}' created successfully")
|
||||
return {"success": True, "link": link_name}
|
||||
|
||||
|
||||
@router.put("/update/{link_name}")
|
||||
async def update_link(
|
||||
link_name: str,
|
||||
request: LinkCreateRequest,
|
||||
_: str = Depends(verify_token),
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing link.
|
||||
|
||||
Args:
|
||||
link_name: Link name.
|
||||
request: Updated link configuration.
|
||||
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name not in settings.links:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Link '{link_name}' not found. Use POST /api/links/create/{link_name} to create it.",
|
||||
)
|
||||
|
||||
link_config = LinkConfig(**request.model_dump())
|
||||
|
||||
try:
|
||||
config_manager.update_link(link_name, link_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update link '{link_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update link: {str(e)}",
|
||||
)
|
||||
|
||||
await ws_manager.broadcast_links_changed()
|
||||
logger.info(f"Link '{link_name}' updated successfully")
|
||||
return {"success": True, "link": link_name}
|
||||
|
||||
|
||||
@router.delete("/delete/{link_name}")
|
||||
async def delete_link(
|
||||
link_name: str,
|
||||
_: str = Depends(verify_token),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete a link.
|
||||
|
||||
Args:
|
||||
link_name: Link name.
|
||||
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name not in settings.links:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Link '{link_name}' not found",
|
||||
)
|
||||
|
||||
try:
|
||||
config_manager.delete_link(link_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete link '{link_name}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete link: {str(e)}",
|
||||
)
|
||||
|
||||
await ws_manager.broadcast_links_changed()
|
||||
logger.info(f"Link '{link_name}' deleted successfully")
|
||||
return {"success": True, "link": link_name}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Media control API endpoints."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
@@ -17,6 +18,39 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/media", tags=["media"])
|
||||
|
||||
|
||||
def _run_callback(callback_name: str) -> None:
|
||||
"""Fire-and-forget a callback if configured. Failures are logged but don't block."""
|
||||
if not settings.callbacks or callback_name not in settings.callbacks:
|
||||
return
|
||||
|
||||
async def _execute():
|
||||
from .scripts import _run_script
|
||||
|
||||
try:
|
||||
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"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Callback %s error: %s", callback_name, e)
|
||||
|
||||
asyncio.create_task(_execute())
|
||||
|
||||
|
||||
@router.get("/status", response_model=MediaStatus)
|
||||
async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus:
|
||||
"""Get current media playback status.
|
||||
@@ -42,6 +76,7 @@ async def play(_: str = Depends(verify_token)) -> dict:
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Failed to start playback - no active media session",
|
||||
)
|
||||
_run_callback("on_play")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -59,6 +94,7 @@ async def pause(_: str = Depends(verify_token)) -> dict:
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Failed to pause - no active media session",
|
||||
)
|
||||
_run_callback("on_pause")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -76,6 +112,7 @@ async def stop(_: str = Depends(verify_token)) -> dict:
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Failed to stop - no active media session",
|
||||
)
|
||||
_run_callback("on_stop")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -93,6 +130,7 @@ async def next_track(_: str = Depends(verify_token)) -> dict:
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Failed to skip - no active media session",
|
||||
)
|
||||
_run_callback("on_next")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -110,6 +148,7 @@ async def previous_track(_: str = Depends(verify_token)) -> dict:
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Failed to go back - no active media session",
|
||||
)
|
||||
_run_callback("on_previous")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -132,6 +171,7 @@ async def set_volume(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Failed to set volume",
|
||||
)
|
||||
_run_callback("on_volume")
|
||||
return {"success": True, "volume": request.volume}
|
||||
|
||||
|
||||
@@ -144,6 +184,7 @@ async def toggle_mute(_: str = Depends(verify_token)) -> dict:
|
||||
"""
|
||||
controller = get_media_controller()
|
||||
muted = await controller.toggle_mute()
|
||||
_run_callback("on_mute")
|
||||
return {"success": True, "muted": muted}
|
||||
|
||||
|
||||
@@ -164,9 +205,43 @@ async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict:
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Failed to seek - no active media session or seek not supported",
|
||||
)
|
||||
_run_callback("on_seek")
|
||||
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
|
||||
"""
|
||||
_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
|
||||
"""
|
||||
_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
|
||||
"""
|
||||
_run_callback("on_toggle")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/artwork")
|
||||
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
||||
"""Get the current album artwork.
|
||||
@@ -193,6 +268,53 @@ async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
||||
return Response(content=art_bytes, media_type=content_type)
|
||||
|
||||
|
||||
@router.get("/visualizer/status")
|
||||
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
|
||||
"""Check if audio visualizer is available and running."""
|
||||
from ..services.audio_analyzer import get_audio_analyzer
|
||||
|
||||
analyzer = get_audio_analyzer()
|
||||
return {
|
||||
"available": analyzer.available,
|
||||
"running": analyzer.running,
|
||||
"current_device": analyzer.current_device,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/visualizer/devices")
|
||||
async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]:
|
||||
"""List available loopback audio devices for the visualizer."""
|
||||
from ..services.audio_analyzer import AudioAnalyzer
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
|
||||
|
||||
|
||||
@router.post("/visualizer/device")
|
||||
async def set_visualizer_device(
|
||||
request: dict,
|
||||
_: str = Depends(verify_token),
|
||||
) -> dict:
|
||||
"""Set the loopback audio device for the visualizer.
|
||||
|
||||
Body: {"device_name": "Device Name" | null}
|
||||
Passing null resets to auto-detect.
|
||||
"""
|
||||
from ..services.audio_analyzer import get_audio_analyzer
|
||||
|
||||
device_name = request.get("device_name")
|
||||
analyzer = get_audio_analyzer()
|
||||
|
||||
# set_device() handles stop/start internally if capture was running
|
||||
success = analyzer.set_device(device_name)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"current_device": analyzer.current_device,
|
||||
"running": analyzer.running,
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
@@ -213,10 +335,16 @@ async def websocket_endpoint(
|
||||
- {"type": "get_status"} - Request current status
|
||||
"""
|
||||
# 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")
|
||||
return
|
||||
|
||||
# Set label in context for logging
|
||||
token_label_var.set(label)
|
||||
|
||||
await ws_manager.connect(websocket)
|
||||
|
||||
try:
|
||||
@@ -234,6 +362,16 @@ async def websocket_endpoint(
|
||||
"type": "status",
|
||||
"data": status_data.model_dump(),
|
||||
})
|
||||
elif data.get("type") == "volume":
|
||||
# Low-latency volume control via WebSocket
|
||||
volume = data.get("volume")
|
||||
if volume is not None:
|
||||
controller = get_media_controller()
|
||||
await controller.set_volume(int(volume))
|
||||
elif data.get("type") == "enable_visualizer":
|
||||
await ws_manager.subscribe_visualizer(websocket)
|
||||
elif data.get("type") == "disable_visualizer":
|
||||
await ws_manager.unsubscribe_visualizer(websocket)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await ws_manager.disconnect(websocket)
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..config import settings
|
||||
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"])
|
||||
|
||||
# Dedicated executor for script/subprocess execution (avoids blocking the default pool)
|
||||
_script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -30,6 +38,7 @@ class ScriptExecuteResponse(BaseModel):
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
error: str | None = None
|
||||
execution_time: float | None = None
|
||||
|
||||
|
||||
class ScriptInfo(BaseModel):
|
||||
@@ -37,6 +46,7 @@ class ScriptInfo(BaseModel):
|
||||
|
||||
name: str
|
||||
label: str
|
||||
command: str
|
||||
description: str
|
||||
icon: str | None = None
|
||||
timeout: int
|
||||
@@ -53,6 +63,7 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
|
||||
ScriptInfo(
|
||||
name=name,
|
||||
label=config.label or name.replace("_", " ").title(),
|
||||
command=config.command,
|
||||
description=config.description,
|
||||
icon=config.icon,
|
||||
timeout=config.timeout,
|
||||
@@ -82,7 +93,6 @@ async def execute_script(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
|
||||
)
|
||||
|
||||
script_config = settings.scripts[script_name]
|
||||
args = request.args if request else []
|
||||
|
||||
@@ -95,10 +105,10 @@ async def execute_script(
|
||||
# Append arguments to command
|
||||
command = f"{command} {' '.join(args)}"
|
||||
|
||||
# Execute in thread pool to not block
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
_script_executor,
|
||||
lambda: _run_script(
|
||||
command=command,
|
||||
timeout=script_config.timeout,
|
||||
@@ -113,6 +123,7 @@ async def execute_script(
|
||||
exit_code=result["exit_code"],
|
||||
stdout=result["stdout"],
|
||||
stderr=result["stderr"],
|
||||
execution_time=result.get("execution_time"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -139,8 +150,9 @@ def _run_script(
|
||||
working_dir: Working directory
|
||||
|
||||
Returns:
|
||||
Dict with exit_code, stdout, stderr
|
||||
Dict with exit_code, stdout, stderr, execution_time
|
||||
"""
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
@@ -150,20 +162,208 @@ def _run_script(
|
||||
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"Script 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,
|
||||
}
|
||||
|
||||
|
||||
# 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,10 +0,0 @@
|
||||
# Get the project root directory (two levels up from this script)
|
||||
$projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute "python" -Argument "-m media_server.main" -WorkingDirectory $projectRoot
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant"
|
||||
|
||||
Write-Host "Scheduled task 'MediaServer' created with working directory: $projectRoot"
|
||||
@@ -41,8 +41,9 @@ def get_media_controller() -> "MediaController":
|
||||
|
||||
if system == "Windows":
|
||||
from .windows_media import WindowsMediaController
|
||||
from ..config import settings
|
||||
|
||||
_controller_instance = WindowsMediaController()
|
||||
_controller_instance = WindowsMediaController(audio_device=settings.audio_device)
|
||||
elif system == "Linux":
|
||||
# Check if running on Android
|
||||
if _is_android():
|
||||
@@ -72,4 +73,13 @@ def get_current_album_art() -> bytes | 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"]
|
||||
|
||||
318
media_server/services/audio_analyzer.py
Normal file
318
media_server/services/audio_analyzer.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""Audio spectrum analyzer service using system loopback capture."""
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_np = None
|
||||
_sc = None
|
||||
|
||||
|
||||
def _load_numpy():
|
||||
global _np
|
||||
if _np is None:
|
||||
try:
|
||||
import numpy as np
|
||||
_np = np
|
||||
except ImportError:
|
||||
logger.info("numpy not installed - audio visualizer unavailable")
|
||||
return _np
|
||||
|
||||
|
||||
def _load_soundcard():
|
||||
global _sc
|
||||
if _sc is None:
|
||||
try:
|
||||
import soundcard as sc
|
||||
_sc = sc
|
||||
except ImportError:
|
||||
logger.info("soundcard not installed - audio visualizer unavailable")
|
||||
return _sc
|
||||
|
||||
|
||||
class AudioAnalyzer:
|
||||
"""Captures system audio loopback and performs real-time FFT analysis."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_bins: int = 32,
|
||||
sample_rate: int = 44100,
|
||||
chunk_size: int = 1024,
|
||||
target_fps: int = 30,
|
||||
device_name: str | None = None,
|
||||
):
|
||||
self.num_bins = num_bins
|
||||
self.sample_rate = sample_rate
|
||||
self.chunk_size = chunk_size
|
||||
self.target_fps = target_fps
|
||||
self.device_name = device_name
|
||||
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
|
||||
# Pre-compute logarithmic bin edges
|
||||
self._bin_edges = self._compute_bin_edges()
|
||||
|
||||
def _compute_bin_edges(self) -> list[int]:
|
||||
"""Compute logarithmic frequency bin boundaries for perceptual grouping."""
|
||||
np = _load_numpy()
|
||||
if np is None:
|
||||
return []
|
||||
|
||||
fft_size = self.chunk_size // 2 + 1
|
||||
min_freq = 20.0
|
||||
max_freq = min(16000.0, self.sample_rate / 2)
|
||||
|
||||
edges = []
|
||||
for i in range(self.num_bins + 1):
|
||||
freq = min_freq * (max_freq / min_freq) ** (i / self.num_bins)
|
||||
bin_idx = int(freq * self.chunk_size / self.sample_rate)
|
||||
edges.append(min(bin_idx, fft_size - 1))
|
||||
return edges
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Whether audio capture dependencies are available."""
|
||||
return _load_numpy() is not None and _load_soundcard() is not None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Whether capture is currently active."""
|
||||
return self._running
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start audio capture in a background thread. Returns False if unavailable."""
|
||||
with self._lifecycle_lock:
|
||||
if self._running:
|
||||
return True
|
||||
if not self.available:
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop audio capture and cleanup."""
|
||||
with self._lifecycle_lock:
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
self._thread = None
|
||||
with self._lock:
|
||||
self._data = None
|
||||
|
||||
def get_frequency_data(self) -> dict | None:
|
||||
"""Return latest frequency data (thread-safe). None if not running."""
|
||||
with self._lock:
|
||||
return self._data
|
||||
|
||||
@staticmethod
|
||||
def list_loopback_devices() -> list[dict[str, str]]:
|
||||
"""List all available loopback audio devices."""
|
||||
sc = _load_soundcard()
|
||||
if sc is None:
|
||||
return []
|
||||
|
||||
devices = []
|
||||
try:
|
||||
# COM may be needed on Windows for WASAPI
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import comtypes
|
||||
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
loopback_mics = sc.all_microphones(include_loopback=True)
|
||||
for mic in loopback_mics:
|
||||
if mic.isloopback:
|
||||
devices.append({"id": mic.id, "name": mic.name})
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list loopback devices: %s", e)
|
||||
|
||||
return devices
|
||||
|
||||
def _find_loopback_device(self):
|
||||
"""Find a loopback device for system audio capture."""
|
||||
sc = _load_soundcard()
|
||||
if sc is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
loopback_mics = sc.all_microphones(include_loopback=True)
|
||||
|
||||
# If a specific device is requested, find it by name (partial match)
|
||||
if self.device_name:
|
||||
target = self.device_name.lower()
|
||||
for mic in loopback_mics:
|
||||
if mic.isloopback and target in mic.name.lower():
|
||||
logger.info("Found requested loopback device: %s", mic.name)
|
||||
self._current_device_name = mic.name
|
||||
return mic
|
||||
logger.warning("Requested device '%s' not found, falling back to default", self.device_name)
|
||||
|
||||
# Default: first loopback device
|
||||
for mic in loopback_mics:
|
||||
if mic.isloopback:
|
||||
logger.info("Found loopback device: %s", mic.name)
|
||||
self._current_device_name = mic.name
|
||||
return mic
|
||||
|
||||
# Fallback: try to get default speaker's loopback
|
||||
default_speaker = sc.default_speaker()
|
||||
if default_speaker:
|
||||
for mic in loopback_mics:
|
||||
if default_speaker.name in mic.name:
|
||||
logger.info("Found speaker loopback: %s", mic.name)
|
||||
self._current_device_name = mic.name
|
||||
return mic
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to find loopback device: %s", e)
|
||||
|
||||
return None
|
||||
|
||||
def set_device(self, device_name: str | None) -> bool:
|
||||
"""Change the loopback device. Restarts capture if running. Returns True on success."""
|
||||
was_running = self._running
|
||||
if was_running:
|
||||
self.stop()
|
||||
|
||||
self.device_name = device_name
|
||||
self._current_device_name = None
|
||||
|
||||
if was_running:
|
||||
return self.start()
|
||||
return True
|
||||
|
||||
@property
|
||||
def current_device(self) -> str | None:
|
||||
"""Return the name of the currently active loopback device."""
|
||||
return self._current_device_name
|
||||
|
||||
def _capture_loop(self) -> None:
|
||||
"""Background thread: capture audio and compute FFT continuously."""
|
||||
# Initialize COM on Windows (required for WASAPI/SoundCard)
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import comtypes
|
||||
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
|
||||
except Exception:
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.ole32.CoInitializeEx(0, 0)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize COM: %s", e)
|
||||
|
||||
np = _load_numpy()
|
||||
sc = _load_soundcard()
|
||||
if np is None or sc is None:
|
||||
self._running = False
|
||||
return
|
||||
|
||||
device = self._find_loopback_device()
|
||||
if device is None:
|
||||
logger.warning("No loopback audio device found - visualizer disabled")
|
||||
self._running = False
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
window = np.hanning(self.chunk_size)
|
||||
|
||||
# Pre-compute bin edge pairs for vectorized grouping
|
||||
edges = self._bin_edges
|
||||
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
|
||||
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
|
||||
|
||||
try:
|
||||
with device.recorder(
|
||||
samplerate=self.sample_rate,
|
||||
channels=1,
|
||||
blocksize=self.chunk_size,
|
||||
) as recorder:
|
||||
logger.info("Audio capture started on: %s", device.name)
|
||||
while self._running:
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
data = recorder.record(numframes=self.chunk_size)
|
||||
except Exception as e:
|
||||
logger.debug("Audio capture read error: %s", e)
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Mono mix if needed
|
||||
if data.ndim > 1:
|
||||
mono = data.mean(axis=1)
|
||||
else:
|
||||
mono = data.ravel()
|
||||
|
||||
if len(mono) < self.chunk_size:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Apply window and compute FFT
|
||||
windowed = mono[:self.chunk_size] * window
|
||||
fft_mag = np.abs(np.fft.rfft(windowed))
|
||||
|
||||
# Group into logarithmic bins (vectorized via cumsum)
|
||||
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
|
||||
counts = bin_ends - bin_starts
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
||||
|
||||
# Normalize to 0-1
|
||||
max_val = bins.max()
|
||||
if max_val > 0:
|
||||
bins *= (1.0 / max_val)
|
||||
|
||||
# Bass energy: average of first 4 bins (~20-200Hz)
|
||||
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
||||
|
||||
# Round for compact JSON
|
||||
frequencies = np.round(bins, 3).tolist()
|
||||
bass = round(bass, 3)
|
||||
|
||||
with self._lock:
|
||||
self._data = {"frequencies": frequencies, "bass": bass}
|
||||
|
||||
# Throttle to target FPS
|
||||
elapsed = time.monotonic() - t0
|
||||
if elapsed < interval:
|
||||
time.sleep(interval - elapsed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Audio capture loop error: %s", e)
|
||||
finally:
|
||||
self._running = False
|
||||
logger.info("Audio capture stopped")
|
||||
|
||||
|
||||
# Global singleton
|
||||
_analyzer: AudioAnalyzer | None = None
|
||||
|
||||
|
||||
def get_audio_analyzer(
|
||||
num_bins: int = 32,
|
||||
sample_rate: int = 44100,
|
||||
target_fps: int = 25,
|
||||
device_name: str | None = None,
|
||||
) -> AudioAnalyzer:
|
||||
"""Get or create the global AudioAnalyzer instance."""
|
||||
global _analyzer
|
||||
if _analyzer is None:
|
||||
_analyzer = AudioAnalyzer(
|
||||
num_bins=num_bins,
|
||||
sample_rate=sample_rate,
|
||||
target_fps=target_fps,
|
||||
device_name=device_name,
|
||||
)
|
||||
return _analyzer
|
||||
329
media_server/services/browser_service.py
Normal file
329
media_server/services/browser_service.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""Browser service for media file browsing and path validation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat as stat_module
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..config import settings
|
||||
|
||||
# Directory listing cache: {resolved_path_str: (timestamp, all_items)}
|
||||
_dir_cache: dict[str, tuple[float, list[dict]]] = {}
|
||||
DIR_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
# Media info cache: {(file_path_str, mtime): {duration, bitrate, title}}
|
||||
_media_info_cache: dict[tuple[str, float], dict] = {}
|
||||
_MEDIA_INFO_CACHE_MAX = 5000
|
||||
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
HAS_MUTAGEN = True
|
||||
except ImportError:
|
||||
HAS_MUTAGEN = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Media file extensions
|
||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".flac", ".wav", ".ogg", ".aac", ".wma", ".opus"}
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".flv"}
|
||||
MEDIA_EXTENSIONS = AUDIO_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
class BrowserService:
|
||||
"""Service for browsing media files with path validation."""
|
||||
|
||||
@staticmethod
|
||||
def validate_path(folder_id: str, requested_path: str) -> Path:
|
||||
"""Validate and resolve a path within an allowed folder.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the configured media folder.
|
||||
requested_path: Path to validate (relative to folder root or absolute).
|
||||
|
||||
Returns:
|
||||
Resolved absolute Path object.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder_id invalid or path traversal attempted.
|
||||
FileNotFoundError: If path does not exist.
|
||||
"""
|
||||
# Get folder config
|
||||
if folder_id not in settings.media_folders:
|
||||
raise ValueError(f"Media folder '{folder_id}' not configured")
|
||||
|
||||
folder_config = settings.media_folders[folder_id]
|
||||
if not folder_config.enabled:
|
||||
raise ValueError(f"Media folder '{folder_id}' is disabled")
|
||||
|
||||
# Get base path
|
||||
base_path = Path(folder_config.path).resolve()
|
||||
if not base_path.exists():
|
||||
raise FileNotFoundError(f"Media folder path does not exist: {base_path}")
|
||||
if not base_path.is_dir():
|
||||
raise ValueError(f"Media folder path is not a directory: {base_path}")
|
||||
|
||||
# Handle relative vs absolute paths
|
||||
if requested_path.startswith("/") or requested_path.startswith("\\"):
|
||||
# Relative to folder root (remove leading slash)
|
||||
requested_path = requested_path.lstrip("/\\")
|
||||
|
||||
# Build and resolve full path
|
||||
if requested_path:
|
||||
full_path = (base_path / requested_path).resolve()
|
||||
else:
|
||||
full_path = base_path
|
||||
|
||||
# Security check: Ensure resolved path is within base path
|
||||
try:
|
||||
full_path.relative_to(base_path)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Path traversal attempt detected: {requested_path} "
|
||||
f"(resolved to {full_path}, base: {base_path})"
|
||||
)
|
||||
raise ValueError("Path traversal not allowed")
|
||||
|
||||
# Check if path exists
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"Path does not exist: {requested_path}")
|
||||
|
||||
return full_path
|
||||
|
||||
@staticmethod
|
||||
def is_media_file(path: Path) -> bool:
|
||||
"""Check if a file is a media file based on extension.
|
||||
|
||||
Args:
|
||||
path: Path to check.
|
||||
|
||||
Returns:
|
||||
True if file is a media file, False otherwise.
|
||||
"""
|
||||
return path.suffix.lower() in MEDIA_EXTENSIONS
|
||||
|
||||
@staticmethod
|
||||
def get_file_type(path: Path) -> str:
|
||||
"""Get the file type (folder, audio, video, other).
|
||||
|
||||
Args:
|
||||
path: Path to check.
|
||||
|
||||
Returns:
|
||||
File type string: "folder", "audio", "video", or "other".
|
||||
"""
|
||||
if path.is_dir():
|
||||
return "folder"
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
return "audio"
|
||||
elif suffix in VIDEO_EXTENSIONS:
|
||||
return "video"
|
||||
else:
|
||||
return "other"
|
||||
|
||||
@staticmethod
|
||||
def get_media_info(file_path: Path, mtime: float | None = None) -> dict:
|
||||
"""Get duration, bitrate, and title of a media file (header-only read).
|
||||
|
||||
Results are cached by (path, mtime) to avoid re-reading unchanged files.
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file.
|
||||
mtime: File modification time (avoids an extra stat call).
|
||||
|
||||
Returns:
|
||||
Dict with 'duration' (float or None), 'bitrate' (int or None),
|
||||
and 'title' (str or None).
|
||||
"""
|
||||
result = {"duration": None, "bitrate": None, "title": None}
|
||||
if not HAS_MUTAGEN:
|
||||
return result
|
||||
|
||||
# Use mtime-based cache to skip mutagen reads for unchanged files
|
||||
if mtime is None:
|
||||
try:
|
||||
mtime = file_path.stat().st_mtime
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
if mtime is not None:
|
||||
cache_key = (str(file_path), mtime)
|
||||
cached = _media_info_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
audio = MutagenFile(str(file_path), easy=True)
|
||||
if audio is not None and hasattr(audio, "info"):
|
||||
if hasattr(audio.info, "length"):
|
||||
result["duration"] = round(audio.info.length, 2)
|
||||
if hasattr(audio.info, "bitrate") and audio.info.bitrate:
|
||||
result["bitrate"] = audio.info.bitrate
|
||||
if audio is not None and hasattr(audio, "tags") and audio.tags:
|
||||
tags = audio.tags
|
||||
title = None
|
||||
artist = None
|
||||
if "title" in tags:
|
||||
title = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
|
||||
if "artist" in tags:
|
||||
artist = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
|
||||
if artist and title:
|
||||
result["title"] = f"{artist} \u2013 {title}"
|
||||
elif title:
|
||||
result["title"] = title
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cache result (evict oldest entries if cache is full)
|
||||
if mtime is not None:
|
||||
if len(_media_info_cache) >= _MEDIA_INFO_CACHE_MAX:
|
||||
# Remove oldest ~20% of entries
|
||||
to_remove = list(_media_info_cache.keys())[:_MEDIA_INFO_CACHE_MAX // 5]
|
||||
for k in to_remove:
|
||||
del _media_info_cache[k]
|
||||
_media_info_cache[(str(file_path), mtime)] = result
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def browse_directory(
|
||||
folder_id: str,
|
||||
path: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
nocache: bool = False,
|
||||
) -> dict:
|
||||
"""Browse a directory and return items with metadata.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the configured media folder.
|
||||
path: Path to browse (relative to folder root).
|
||||
offset: Pagination offset (default: 0).
|
||||
limit: Maximum items to return (default: 100).
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- current_path: Current path (relative to folder root)
|
||||
- parent_path: Parent path (None if at root)
|
||||
- items: List of file/folder items
|
||||
- total: Total number of items
|
||||
- offset: Current offset
|
||||
- limit: Current limit
|
||||
- folder_id: Folder ID
|
||||
|
||||
Raises:
|
||||
ValueError: If path validation fails.
|
||||
FileNotFoundError: If path does not exist.
|
||||
"""
|
||||
# Validate path
|
||||
full_path = BrowserService.validate_path(folder_id, path)
|
||||
|
||||
# Get base path for relative path calculation
|
||||
folder_config = settings.media_folders[folder_id]
|
||||
base_path = Path(folder_config.path).resolve()
|
||||
|
||||
# Check if it's a directory
|
||||
if not full_path.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {path}")
|
||||
|
||||
# Calculate relative path
|
||||
try:
|
||||
relative_path = full_path.relative_to(base_path)
|
||||
current_path = "/" + str(relative_path).replace("\\", "/") if str(relative_path) != "." else "/"
|
||||
except ValueError:
|
||||
current_path = "/"
|
||||
|
||||
# Calculate parent path
|
||||
if full_path == base_path:
|
||||
parent_path = None
|
||||
else:
|
||||
parent_relative = full_path.parent.relative_to(base_path)
|
||||
parent_path = "/" + str(parent_relative).replace("\\", "/") if str(parent_relative) != "." else "/"
|
||||
|
||||
# List directory contents (with caching)
|
||||
try:
|
||||
cache_key = str(full_path)
|
||||
now = time.monotonic()
|
||||
|
||||
# Check cache
|
||||
if not nocache and cache_key in _dir_cache:
|
||||
cached_time, cached_items = _dir_cache[cache_key]
|
||||
if now - cached_time < DIR_CACHE_TTL:
|
||||
all_items = cached_items
|
||||
else:
|
||||
del _dir_cache[cache_key]
|
||||
all_items = None
|
||||
else:
|
||||
all_items = None
|
||||
|
||||
# Enumerate directory if not cached
|
||||
if all_items is None:
|
||||
all_items = []
|
||||
for item in full_path.iterdir():
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Single stat() call per item — reuse for type check and metadata
|
||||
try:
|
||||
st = item.stat()
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
|
||||
if stat_module.S_ISDIR(st.st_mode):
|
||||
file_type = "folder"
|
||||
else:
|
||||
suffix = item.suffix.lower()
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
file_type = "audio"
|
||||
elif suffix in VIDEO_EXTENSIONS:
|
||||
file_type = "video"
|
||||
else:
|
||||
continue
|
||||
|
||||
all_items.append({
|
||||
"name": item.name,
|
||||
"type": file_type,
|
||||
"is_media": file_type in ("audio", "video"),
|
||||
"_size": st.st_size,
|
||||
"_mtime": st.st_mtime,
|
||||
})
|
||||
|
||||
all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower()))
|
||||
_dir_cache[cache_key] = (now, all_items)
|
||||
|
||||
# Apply pagination
|
||||
total = len(all_items)
|
||||
items = all_items[offset:offset + limit]
|
||||
|
||||
# Enrich items on the current page with metadata
|
||||
for item in items:
|
||||
item["size"] = item["_size"] if item["type"] != "folder" else None
|
||||
item["modified"] = datetime.fromtimestamp(item["_mtime"]).isoformat()
|
||||
|
||||
if item["is_media"]:
|
||||
item_path = full_path / item["name"]
|
||||
info = BrowserService.get_media_info(item_path, item["_mtime"])
|
||||
item["duration"] = info["duration"]
|
||||
item["bitrate"] = info["bitrate"]
|
||||
item["title"] = info["title"]
|
||||
else:
|
||||
item["duration"] = None
|
||||
item["bitrate"] = None
|
||||
item["title"] = None
|
||||
|
||||
return {
|
||||
"folder_id": folder_id,
|
||||
"current_path": current_path,
|
||||
"parent_path": parent_path,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
except PermissionError:
|
||||
raise ValueError(f"Permission denied accessing path: {path}")
|
||||
271
media_server/services/display_service.py
Normal file
271
media_server/services/display_service.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""Display brightness and power control service."""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import logging
|
||||
import platform
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_sbc = None
|
||||
_monitorcontrol = None
|
||||
|
||||
|
||||
def _load_sbc():
|
||||
global _sbc
|
||||
if _sbc is None:
|
||||
try:
|
||||
import screen_brightness_control as sbc
|
||||
_sbc = sbc
|
||||
except ImportError:
|
||||
logger.warning("screen_brightness_control not installed - brightness control unavailable")
|
||||
return _sbc
|
||||
|
||||
|
||||
def _load_monitorcontrol():
|
||||
global _monitorcontrol
|
||||
if _monitorcontrol is None:
|
||||
try:
|
||||
import monitorcontrol
|
||||
_monitorcontrol = monitorcontrol
|
||||
except ImportError:
|
||||
logger.warning("monitorcontrol not installed - display power control unavailable")
|
||||
return _monitorcontrol
|
||||
|
||||
|
||||
def _parse_edid_resolution(edid_hex: str) -> str | None:
|
||||
"""Parse resolution from EDID hex string (first detailed timing descriptor)."""
|
||||
try:
|
||||
edid = bytes.fromhex(edid_hex)
|
||||
if len(edid) < 58:
|
||||
return None
|
||||
dtd = edid[54:]
|
||||
pixel_clock = struct.unpack('<H', dtd[0:2])[0]
|
||||
if pixel_clock == 0:
|
||||
return None
|
||||
h_active = dtd[2] | ((dtd[4] >> 4) << 8)
|
||||
v_active = dtd[5] | ((dtd[7] >> 4) << 8)
|
||||
return f"{h_active}x{v_active}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitorInfo:
|
||||
id: int
|
||||
name: str
|
||||
brightness: int | None
|
||||
power_supported: bool
|
||||
power_on: bool = True
|
||||
model: str = ""
|
||||
manufacturer: str = ""
|
||||
resolution: str | None = None
|
||||
is_primary: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"brightness": self.brightness,
|
||||
"power_supported": self.power_supported,
|
||||
"power_on": self.power_on,
|
||||
"model": self.model,
|
||||
"manufacturer": self.manufacturer,
|
||||
"resolution": self.resolution,
|
||||
"is_primary": self.is_primary,
|
||||
}
|
||||
|
||||
|
||||
def _detect_primary_resolution() -> str | None:
|
||||
"""Detect the primary display resolution via Windows API."""
|
||||
if platform.system() != "Windows":
|
||||
return None
|
||||
try:
|
||||
class MONITORINFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", ctypes.wintypes.DWORD),
|
||||
("rcMonitor", ctypes.wintypes.RECT),
|
||||
("rcWork", ctypes.wintypes.RECT),
|
||||
("dwFlags", ctypes.wintypes.DWORD),
|
||||
]
|
||||
|
||||
MONITORINFOF_PRIMARY = 1
|
||||
primary_res = None
|
||||
|
||||
def callback(hmon, hdc, rect, data):
|
||||
nonlocal primary_res
|
||||
mi = MONITORINFO()
|
||||
mi.cbSize = ctypes.sizeof(mi)
|
||||
ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(mi))
|
||||
if mi.dwFlags & MONITORINFOF_PRIMARY:
|
||||
w = mi.rcMonitor.right - mi.rcMonitor.left
|
||||
h = mi.rcMonitor.bottom - mi.rcMonitor.top
|
||||
primary_res = f"{w}x{h}"
|
||||
return True
|
||||
|
||||
MONITORENUMPROC = ctypes.WINFUNCTYPE(
|
||||
ctypes.c_int,
|
||||
ctypes.wintypes.HMONITOR,
|
||||
ctypes.wintypes.HDC,
|
||||
ctypes.POINTER(ctypes.wintypes.RECT),
|
||||
ctypes.wintypes.LPARAM,
|
||||
)
|
||||
ctypes.windll.user32.EnumDisplayMonitors(
|
||||
None, None, MONITORENUMPROC(callback), 0
|
||||
)
|
||||
return primary_res
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _mark_primary(monitors: list[MonitorInfo]) -> None:
|
||||
"""Mark the primary display in the monitor list."""
|
||||
if not monitors:
|
||||
return
|
||||
|
||||
primary_res = _detect_primary_resolution()
|
||||
if primary_res:
|
||||
for m in monitors:
|
||||
if m.resolution == primary_res:
|
||||
m.is_primary = True
|
||||
return
|
||||
|
||||
# Fallback: mark first monitor as primary
|
||||
monitors[0].is_primary = True
|
||||
|
||||
|
||||
# Cache for monitor list
|
||||
_monitor_cache: list[MonitorInfo] | None = None
|
||||
_cache_time: float = 0
|
||||
_CACHE_TTL = 5.0 # seconds
|
||||
|
||||
|
||||
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
"""List all connected monitors with their current brightness."""
|
||||
global _monitor_cache, _cache_time
|
||||
|
||||
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL:
|
||||
return _monitor_cache
|
||||
|
||||
sbc = _load_sbc()
|
||||
if sbc is None:
|
||||
return []
|
||||
|
||||
monitors = []
|
||||
try:
|
||||
info_list = sbc.list_monitors_info()
|
||||
brightnesses = sbc.get_brightness()
|
||||
|
||||
# Get DDC/CI monitors for power state
|
||||
mc = _load_monitorcontrol()
|
||||
ddc_monitors = []
|
||||
if mc:
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for i, info in enumerate(info_list):
|
||||
name = info.get("name", f"Monitor {i}")
|
||||
model = info.get("model", "")
|
||||
manufacturer = info.get("manufacturer", "")
|
||||
brightness = brightnesses[i] if i < len(brightnesses) else None
|
||||
# VCP method monitors support DDC/CI power control
|
||||
method = str(info.get("method", "")).lower()
|
||||
power_supported = "vcp" in method
|
||||
|
||||
# Parse resolution from EDID
|
||||
edid = info.get("edid", "")
|
||||
resolution = _parse_edid_resolution(edid) if edid else None
|
||||
|
||||
# Read power state via DDC/CI
|
||||
power_on = True
|
||||
if power_supported and i < len(ddc_monitors):
|
||||
try:
|
||||
with ddc_monitors[i] as mon:
|
||||
power_mode = mon.get_power_mode()
|
||||
power_on = power_mode == mc.PowerMode.on
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
monitors.append(MonitorInfo(
|
||||
id=i,
|
||||
name=name,
|
||||
brightness=brightness,
|
||||
power_supported=power_supported,
|
||||
power_on=power_on,
|
||||
model=model,
|
||||
manufacturer=manufacturer,
|
||||
resolution=resolution,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error("Failed to enumerate monitors: %s", e)
|
||||
|
||||
_mark_primary(monitors)
|
||||
_monitor_cache = monitors
|
||||
_cache_time = time.time()
|
||||
return monitors
|
||||
|
||||
|
||||
def get_brightness(monitor_id: int) -> int | None:
|
||||
"""Get brightness for a specific monitor."""
|
||||
sbc = _load_sbc()
|
||||
if sbc is None:
|
||||
return None
|
||||
try:
|
||||
result = sbc.get_brightness(display=monitor_id)
|
||||
if isinstance(result, list):
|
||||
return result[0] if result else None
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Failed to get brightness for monitor %d: %s", monitor_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def set_brightness(monitor_id: int, value: int) -> bool:
|
||||
"""Set brightness for a specific monitor (0-100)."""
|
||||
sbc = _load_sbc()
|
||||
if sbc is None:
|
||||
return False
|
||||
|
||||
value = max(0, min(100, value))
|
||||
try:
|
||||
sbc.set_brightness(value, display=monitor_id)
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_power(monitor_id: int, on: bool) -> bool:
|
||||
"""Turn a monitor on or off via DDC/CI."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
logger.error("monitorcontrol not available for power control")
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
logger.error("Monitor %d not found in DDC/CI monitors", monitor_id)
|
||||
return False
|
||||
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
if on:
|
||||
monitor.set_power_mode(mc.PowerMode.on)
|
||||
else:
|
||||
monitor.set_power_mode(mc.PowerMode.off_soft)
|
||||
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
@@ -293,3 +293,27 @@ class LinuxMediaController(MediaController):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (Linux).
|
||||
|
||||
Uses xdg-open to open the file with the default application.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the media file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'xdg-open', file_path,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
await process.wait()
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
@@ -294,3 +294,27 @@ class MacOSMediaController(MediaController):
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (macOS).
|
||||
|
||||
Uses the 'open' command to open the file with the default application.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the media file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'open', file_path,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
await process.wait()
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
@@ -94,3 +94,15 @@ class MediaController(ABC):
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the media file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
202
media_server/services/metadata_service.py
Normal file
202
media_server/services/metadata_service.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Metadata extraction service for media files."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataService:
|
||||
"""Service for extracting metadata from media files."""
|
||||
|
||||
@staticmethod
|
||||
def extract_audio_metadata(file_path: Path) -> dict:
|
||||
"""Extract metadata from an audio file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the audio file.
|
||||
|
||||
Returns:
|
||||
Dictionary with audio metadata.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
audio = MutagenFile(str(file_path), easy=True)
|
||||
if audio is None:
|
||||
return {"error": "Unable to read audio file"}
|
||||
|
||||
try:
|
||||
metadata = {
|
||||
"type": "audio",
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
}
|
||||
|
||||
# Extract duration
|
||||
if hasattr(audio.info, "length"):
|
||||
metadata["duration"] = round(audio.info.length, 2)
|
||||
|
||||
# Extract bitrate
|
||||
if hasattr(audio.info, "bitrate"):
|
||||
metadata["bitrate"] = audio.info.bitrate
|
||||
|
||||
# Extract sample rate
|
||||
if hasattr(audio.info, "sample_rate"):
|
||||
metadata["sample_rate"] = audio.info.sample_rate
|
||||
elif hasattr(audio.info, "samplerate"):
|
||||
metadata["sample_rate"] = audio.info.samplerate
|
||||
|
||||
# Extract channels
|
||||
if hasattr(audio.info, "channels"):
|
||||
metadata["channels"] = audio.info.channels
|
||||
|
||||
# Extract tags (use easy=True for consistent tag names)
|
||||
if audio is not None and hasattr(audio, "tags") and audio.tags:
|
||||
# Easy tags provide lists, so we take the first item
|
||||
tags = audio.tags
|
||||
|
||||
if "title" in tags:
|
||||
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
|
||||
|
||||
if "artist" in tags:
|
||||
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
|
||||
|
||||
if "album" in tags:
|
||||
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
|
||||
|
||||
if "albumartist" in tags:
|
||||
metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
|
||||
|
||||
if "date" in tags:
|
||||
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
|
||||
|
||||
if "genre" in tags:
|
||||
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
|
||||
|
||||
if "tracknumber" in tags:
|
||||
metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
|
||||
|
||||
# If no title tag, use filename
|
||||
if "title" not in metadata:
|
||||
metadata["title"] = file_path.stem
|
||||
|
||||
return metadata
|
||||
finally:
|
||||
if hasattr(audio, 'close'):
|
||||
audio.close()
|
||||
|
||||
except ImportError:
|
||||
logger.error("mutagen library not installed, cannot extract metadata")
|
||||
return {"error": "mutagen library not installed"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting audio metadata from {file_path}: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"filename": file_path.name,
|
||||
"title": file_path.stem,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def extract_video_metadata(file_path: Path) -> dict:
|
||||
"""Extract basic metadata from a video file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the video file.
|
||||
|
||||
Returns:
|
||||
Dictionary with video metadata.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
video = MutagenFile(str(file_path))
|
||||
if video is None:
|
||||
return {
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"title": file_path.stem,
|
||||
}
|
||||
|
||||
try:
|
||||
metadata = {
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
}
|
||||
|
||||
# Extract duration
|
||||
if hasattr(video.info, "length"):
|
||||
metadata["duration"] = round(video.info.length, 2)
|
||||
|
||||
# Extract bitrate
|
||||
if hasattr(video.info, "bitrate"):
|
||||
metadata["bitrate"] = video.info.bitrate
|
||||
|
||||
# Extract video-specific properties if available
|
||||
if hasattr(video.info, "width"):
|
||||
metadata["width"] = video.info.width
|
||||
|
||||
if hasattr(video.info, "height"):
|
||||
metadata["height"] = video.info.height
|
||||
|
||||
# Try to extract title from tags
|
||||
if hasattr(video, "tags") and video.tags:
|
||||
tags = video.tags
|
||||
if hasattr(tags, "get"):
|
||||
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
|
||||
if title:
|
||||
metadata["title"] = title[0] if isinstance(title, list) else str(title)
|
||||
|
||||
# If no title tag, use filename
|
||||
if "title" not in metadata:
|
||||
metadata["title"] = file_path.stem
|
||||
|
||||
return metadata
|
||||
finally:
|
||||
if hasattr(video, 'close'):
|
||||
video.close()
|
||||
|
||||
except ImportError:
|
||||
logger.error("mutagen library not installed, cannot extract metadata")
|
||||
return {
|
||||
"error": "mutagen library not installed",
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"title": file_path.stem,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Error extracting video metadata from {file_path}: {e}")
|
||||
# Return basic metadata
|
||||
return {
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"title": file_path.stem,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def extract_metadata(file_path: Path) -> dict:
|
||||
"""Extract metadata from a media file (auto-detect type).
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file.
|
||||
|
||||
Returns:
|
||||
Dictionary with media metadata.
|
||||
"""
|
||||
from .browser_service import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS
|
||||
|
||||
suffix = file_path.suffix.lower()
|
||||
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
return MetadataService.extract_audio_metadata(file_path)
|
||||
elif suffix in VIDEO_EXTENSIONS:
|
||||
return MetadataService.extract_video_metadata(file_path)
|
||||
else:
|
||||
return {
|
||||
"error": "Unsupported file type",
|
||||
"filename": file_path.name,
|
||||
}
|
||||
391
media_server/services/thumbnail_service.py
Normal file
391
media_server/services/thumbnail_service.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""Thumbnail generation and caching service."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thumbnail sizes
|
||||
THUMBNAIL_SIZES = {
|
||||
"small": (150, 150),
|
||||
"medium": (300, 300),
|
||||
}
|
||||
|
||||
# Cache size limit (500MB)
|
||||
CACHE_SIZE_LIMIT = 500 * 1024 * 1024 # 500MB in bytes
|
||||
|
||||
|
||||
class ThumbnailService:
|
||||
"""Service for generating and caching thumbnails."""
|
||||
|
||||
@staticmethod
|
||||
def get_cache_dir() -> Path:
|
||||
"""Get the thumbnail cache directory path.
|
||||
|
||||
Returns:
|
||||
Path to the cache directory (project-local).
|
||||
"""
|
||||
# Store cache in project directory: media-server/.cache/thumbnails/
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
cache_dir = project_root / ".cache" / "thumbnails"
|
||||
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
@staticmethod
|
||||
def get_cache_key(file_path: Path) -> str:
|
||||
"""Generate cache key from file path.
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file.
|
||||
|
||||
Returns:
|
||||
SHA256 hash of the absolute file path.
|
||||
"""
|
||||
absolute_path = str(file_path.resolve())
|
||||
return hashlib.sha256(absolute_path.encode()).hexdigest()
|
||||
|
||||
# Sentinel value indicating "no thumbnail available" is cached
|
||||
NO_THUMBNAIL = b""
|
||||
|
||||
@staticmethod
|
||||
def get_cached_thumbnail(file_path: Path, size: str) -> Optional[bytes]:
|
||||
"""Get cached thumbnail if valid.
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file.
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
|
||||
Returns:
|
||||
Thumbnail bytes if cached, empty bytes if cached as "no thumbnail",
|
||||
None if not cached.
|
||||
"""
|
||||
cache_dir = ThumbnailService.get_cache_dir()
|
||||
cache_key = ThumbnailService.get_cache_key(file_path)
|
||||
cache_folder = cache_dir / cache_key
|
||||
cache_path = cache_folder / f"{size}.jpg"
|
||||
no_thumb_path = cache_folder / ".no_thumbnail"
|
||||
|
||||
# Check negative cache first (no thumbnail available)
|
||||
if no_thumb_path.exists():
|
||||
try:
|
||||
file_mtime = file_path.stat().st_mtime
|
||||
cache_mtime = no_thumb_path.stat().st_mtime
|
||||
if file_mtime <= cache_mtime:
|
||||
return ThumbnailService.NO_THUMBNAIL
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
|
||||
# Check if file has been modified since cache was created
|
||||
try:
|
||||
file_mtime = file_path.stat().st_mtime
|
||||
cache_mtime = cache_path.stat().st_mtime
|
||||
|
||||
if file_mtime > cache_mtime:
|
||||
logger.debug(f"Cache invalidated for {file_path.name} (file modified)")
|
||||
return None
|
||||
|
||||
# Read cached thumbnail
|
||||
with open(cache_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.error(f"Error reading cached thumbnail: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def cache_thumbnail(file_path: Path, size: str, image_data: bytes) -> None:
|
||||
"""Cache a thumbnail.
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file.
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
image_data: Thumbnail image data (JPEG bytes).
|
||||
"""
|
||||
cache_dir = ThumbnailService.get_cache_dir()
|
||||
cache_key = ThumbnailService.get_cache_key(file_path)
|
||||
cache_folder = cache_dir / cache_key
|
||||
cache_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cache_path = cache_folder / f"{size}.jpg"
|
||||
|
||||
try:
|
||||
with open(cache_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
logger.debug(f"Cached thumbnail for {file_path.name} ({size})")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.error(f"Error caching thumbnail: {e}")
|
||||
|
||||
@staticmethod
|
||||
def cache_no_thumbnail(file_path: Path) -> None:
|
||||
"""Cache that a file has no thumbnail (negative cache)."""
|
||||
cache_dir = ThumbnailService.get_cache_dir()
|
||||
cache_key = ThumbnailService.get_cache_key(file_path)
|
||||
cache_folder = cache_dir / cache_key
|
||||
cache_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
(cache_folder / ".no_thumbnail").touch()
|
||||
logger.debug(f"Cached no-thumbnail for {file_path.name}")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.error(f"Error caching no-thumbnail marker: {e}")
|
||||
|
||||
@staticmethod
|
||||
def generate_audio_thumbnail(file_path: Path, size: str) -> Optional[bytes]:
|
||||
"""Generate thumbnail from audio file (extract album art).
|
||||
|
||||
Args:
|
||||
file_path: Path to the audio file.
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
|
||||
Returns:
|
||||
Thumbnail bytes (JPEG) or None if no album art.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
audio = MutagenFile(str(file_path))
|
||||
if audio is None:
|
||||
return None
|
||||
|
||||
# Extract album art
|
||||
art_data = None
|
||||
|
||||
# Try different tag types for album art
|
||||
if hasattr(audio, "pictures") and audio.pictures:
|
||||
# FLAC, Ogg Vorbis
|
||||
art_data = audio.pictures[0].data
|
||||
elif hasattr(audio, "tags"):
|
||||
tags = audio.tags
|
||||
if tags is not None:
|
||||
# MP3 (ID3)
|
||||
if hasattr(tags, "getall"):
|
||||
apic_frames = tags.getall("APIC")
|
||||
if apic_frames:
|
||||
art_data = apic_frames[0].data
|
||||
# MP4/M4A
|
||||
elif "covr" in tags:
|
||||
art_data = bytes(tags["covr"][0])
|
||||
# Try other common keys
|
||||
elif "APIC:" in tags:
|
||||
art_data = tags["APIC:"].data
|
||||
|
||||
if art_data is None:
|
||||
return None
|
||||
|
||||
# Resize image
|
||||
img = Image.open(BytesIO(art_data))
|
||||
|
||||
# Convert to RGB if necessary (handle RGBA, grayscale, etc.)
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Resize with maintaining aspect ratio and center crop
|
||||
target_size = THUMBNAIL_SIZES[size]
|
||||
img.thumbnail((target_size[0] * 2, target_size[1] * 2), Image.Resampling.LANCZOS)
|
||||
|
||||
# Center crop to square
|
||||
width, height = img.size
|
||||
min_dim = min(width, height)
|
||||
left = (width - min_dim) // 2
|
||||
top = (height - min_dim) // 2
|
||||
right = left + min_dim
|
||||
bottom = top + min_dim
|
||||
img = img.crop((left, top, right, bottom))
|
||||
|
||||
# Final resize
|
||||
img = img.resize(target_size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Save as JPEG
|
||||
output = BytesIO()
|
||||
img.save(output, format="JPEG", quality=85, optimize=True)
|
||||
return output.getvalue()
|
||||
|
||||
except ImportError:
|
||||
logger.error("Required libraries (mutagen, Pillow) not installed")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error generating audio thumbnail for {file_path.name}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def generate_video_thumbnail(file_path: Path, size: str) -> Optional[bytes]:
|
||||
"""Generate thumbnail from video file using ffmpeg.
|
||||
|
||||
Args:
|
||||
file_path: Path to the video file.
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
|
||||
Returns:
|
||||
Thumbnail bytes (JPEG) or None if ffmpeg not available.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
# Check if ffmpeg is available
|
||||
if not shutil.which("ffmpeg"):
|
||||
logger.debug("ffmpeg not available, cannot generate video thumbnail")
|
||||
return None
|
||||
|
||||
# Extract frame at 10% duration
|
||||
target_size = THUMBNAIL_SIZES[size]
|
||||
|
||||
# Use ffmpeg to extract a frame
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-i", str(file_path),
|
||||
"-vf", f"thumbnail,scale={target_size[0]}:{target_size[1]}:force_original_aspect_ratio=increase,crop={target_size[0]}:{target_size[1]}",
|
||||
"-frames:v", "1",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
"-"
|
||||
]
|
||||
|
||||
# Run ffmpeg with timeout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=10.0)
|
||||
if process.returncode == 0 and stdout:
|
||||
# ffmpeg output is already JPEG, but let's ensure proper quality
|
||||
img = Image.open(BytesIO(stdout))
|
||||
|
||||
# Convert to RGB if necessary
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Save as JPEG with consistent quality
|
||||
output = BytesIO()
|
||||
img.save(output, format="JPEG", quality=85, optimize=True)
|
||||
return output.getvalue()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"ffmpeg timeout for {file_path.name}")
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
return None
|
||||
|
||||
except ImportError:
|
||||
logger.error("Pillow library not installed")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error generating video thumbnail for {file_path.name}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_thumbnail(file_path: Path, size: str = "medium") -> Optional[bytes]:
|
||||
"""Get thumbnail for a media file (from cache or generate).
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file.
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
|
||||
Returns:
|
||||
Thumbnail bytes (JPEG) or None if unavailable.
|
||||
"""
|
||||
from .browser_service import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS
|
||||
|
||||
# Validate size
|
||||
if size not in THUMBNAIL_SIZES:
|
||||
size = "medium"
|
||||
|
||||
# Check cache first (returns bytes, empty bytes for negative cache, or None)
|
||||
cached = ThumbnailService.get_cached_thumbnail(file_path, size)
|
||||
if cached is not None:
|
||||
return cached if cached else None
|
||||
|
||||
# Generate thumbnail based on file type
|
||||
suffix = file_path.suffix.lower()
|
||||
thumbnail_data = None
|
||||
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
# Audio files - run in executor (sync operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
thumbnail_data = await loop.run_in_executor(
|
||||
None,
|
||||
ThumbnailService.generate_audio_thumbnail,
|
||||
file_path,
|
||||
size,
|
||||
)
|
||||
elif suffix in VIDEO_EXTENSIONS:
|
||||
# Video files - already async
|
||||
thumbnail_data = await ThumbnailService.generate_video_thumbnail(file_path, size)
|
||||
|
||||
# Cache result (positive or negative)
|
||||
if thumbnail_data:
|
||||
ThumbnailService.cache_thumbnail(file_path, size, thumbnail_data)
|
||||
else:
|
||||
ThumbnailService.cache_no_thumbnail(file_path)
|
||||
|
||||
return thumbnail_data
|
||||
|
||||
@staticmethod
|
||||
def cleanup_cache() -> None:
|
||||
"""Clean up cache if it exceeds size limit.
|
||||
|
||||
Removes oldest thumbnails by access time.
|
||||
"""
|
||||
cache_dir = ThumbnailService.get_cache_dir()
|
||||
|
||||
try:
|
||||
# Calculate total cache size
|
||||
total_size = 0
|
||||
cache_items = []
|
||||
|
||||
for folder in cache_dir.iterdir():
|
||||
if folder.is_dir():
|
||||
for file in folder.iterdir():
|
||||
if file.is_file():
|
||||
stat = file.stat()
|
||||
total_size += stat.st_size
|
||||
cache_items.append((file, stat.st_atime, stat.st_size))
|
||||
|
||||
# If cache is within limit, no cleanup needed
|
||||
if total_size <= CACHE_SIZE_LIMIT:
|
||||
return
|
||||
|
||||
logger.info(f"Cache size {total_size / 1024 / 1024:.2f}MB exceeds limit, cleaning up...")
|
||||
|
||||
# Sort by access time (oldest first)
|
||||
cache_items.sort(key=lambda x: x[1])
|
||||
|
||||
# Remove oldest items until under limit
|
||||
for file, _, size in cache_items:
|
||||
if total_size <= CACHE_SIZE_LIMIT:
|
||||
break
|
||||
|
||||
try:
|
||||
file.unlink()
|
||||
total_size -= size
|
||||
logger.debug(f"Removed cached thumbnail: {file}")
|
||||
|
||||
# Remove empty parent folder
|
||||
parent = file.parent
|
||||
if parent != cache_dir and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.error(f"Error removing cache file: {e}")
|
||||
|
||||
logger.info(f"Cache cleanup complete, new size: {total_size / 1024 / 1024:.2f}MB")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cache cleanup: {e}")
|
||||
@@ -1,6 +1,7 @@
|
||||
"""WebSocket connection manager and status broadcaster."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Coroutine
|
||||
@@ -23,6 +24,10 @@ class ConnectionManager:
|
||||
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
|
||||
self._last_broadcast_time: float = 0.0
|
||||
self._running: bool = False
|
||||
# Audio visualizer
|
||||
self._visualizer_subscribers: set[WebSocket] = set()
|
||||
self._audio_task: asyncio.Task | None = None
|
||||
self._audio_analyzer = None
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
"""Accept a new WebSocket connection."""
|
||||
@@ -41,33 +46,157 @@ class ConnectionManager:
|
||||
logger.debug("Failed to send initial status: %s", e)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket connection."""
|
||||
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
|
||||
should_stop = False
|
||||
async with self._lock:
|
||||
self._active_connections.discard(websocket)
|
||||
was_subscriber = websocket in self._visualizer_subscribers
|
||||
self._visualizer_subscribers.discard(websocket)
|
||||
if was_subscriber and len(self._visualizer_subscribers) == 0:
|
||||
should_stop = True
|
||||
if should_stop:
|
||||
await self._maybe_stop_capture()
|
||||
logger.info(
|
||||
"WebSocket client disconnected. Total: %d", len(self._active_connections)
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected clients."""
|
||||
"""Broadcast a message to all connected clients concurrently."""
|
||||
async with self._lock:
|
||||
connections = list(self._active_connections)
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
disconnected = []
|
||||
for websocket in connections:
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
await ws.send_json(message)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
disconnected.append(websocket)
|
||||
return ws
|
||||
|
||||
results = await asyncio.gather(*(_send(ws) for ws in connections))
|
||||
|
||||
# Clean up disconnected clients
|
||||
for ws in disconnected:
|
||||
for ws in results:
|
||||
if ws is not None:
|
||||
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")
|
||||
|
||||
async def broadcast_links_changed(self) -> None:
|
||||
"""Notify all connected clients that links have changed."""
|
||||
message = {"type": "links_changed", "data": {}}
|
||||
await self.broadcast(message)
|
||||
logger.info("Broadcast sent: links_changed")
|
||||
|
||||
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
|
||||
should_start = False
|
||||
async with self._lock:
|
||||
self._visualizer_subscribers.add(websocket)
|
||||
if len(self._visualizer_subscribers) == 1 and self._audio_analyzer:
|
||||
should_start = True
|
||||
if should_start:
|
||||
await self._maybe_start_capture()
|
||||
logger.debug("Visualizer subscriber added. Total: %d", len(self._visualizer_subscribers))
|
||||
|
||||
async def unsubscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||
"""Unsubscribe a client from audio visualizer data. Stops capture on last subscriber."""
|
||||
should_stop = False
|
||||
async with self._lock:
|
||||
self._visualizer_subscribers.discard(websocket)
|
||||
if len(self._visualizer_subscribers) == 0:
|
||||
should_stop = True
|
||||
if should_stop:
|
||||
await self._maybe_stop_capture()
|
||||
logger.debug("Visualizer subscriber removed. Total: %d", len(self._visualizer_subscribers))
|
||||
|
||||
async def _maybe_start_capture(self) -> None:
|
||||
"""Start audio capture if not already running (called on first subscriber)."""
|
||||
if self._audio_analyzer and not self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
started = await loop.run_in_executor(None, self._audio_analyzer.start)
|
||||
if started:
|
||||
logger.info("Audio capture started (first subscriber)")
|
||||
else:
|
||||
logger.warning("Audio capture failed to start")
|
||||
|
||||
async def _maybe_stop_capture(self) -> None:
|
||||
"""Stop audio capture if running (called when last subscriber leaves)."""
|
||||
if self._audio_analyzer and self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._audio_analyzer.stop)
|
||||
logger.info("Audio capture stopped (no subscribers)")
|
||||
|
||||
async def start_audio_monitor(self, analyzer) -> None:
|
||||
"""Register the audio analyzer. Capture starts on-demand when clients subscribe."""
|
||||
self._audio_analyzer = analyzer
|
||||
if analyzer and analyzer.available:
|
||||
self._audio_task = asyncio.create_task(self._audio_broadcast_loop())
|
||||
logger.info("Audio visualizer broadcast loop started (capture on-demand)")
|
||||
|
||||
async def stop_audio_monitor(self) -> None:
|
||||
"""Stop audio frequency broadcasting."""
|
||||
if self._audio_task:
|
||||
self._audio_task.cancel()
|
||||
try:
|
||||
await self._audio_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._audio_task = None
|
||||
|
||||
async def _audio_broadcast_loop(self) -> None:
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
|
||||
from ..config import settings
|
||||
interval = 1.0 / settings.visualizer_fps
|
||||
|
||||
_last_data = None
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with self._lock:
|
||||
subscribers = list(self._visualizer_subscribers)
|
||||
|
||||
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
data = self._audio_analyzer.get_frequency_data()
|
||||
if data is None or data is _last_data:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
_last_data = data
|
||||
|
||||
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
|
||||
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
||||
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await ws.send_text(text)
|
||||
return None
|
||||
except Exception:
|
||||
return ws
|
||||
|
||||
results = await asyncio.gather(*(_send(ws) for ws in subscribers))
|
||||
|
||||
failed = [ws for ws in results if ws is not None]
|
||||
for ws in failed:
|
||||
await self.disconnect(ws)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in audio broadcast: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
) -> bool:
|
||||
@@ -150,7 +279,10 @@ class ConnectionManager:
|
||||
async with self._lock:
|
||||
has_clients = len(self._active_connections) > 0
|
||||
|
||||
if has_clients:
|
||||
if not has_clients:
|
||||
await asyncio.sleep(2.0) # Sleep longer when no clients connected
|
||||
continue
|
||||
|
||||
status = await get_status_func()
|
||||
status_dict = status.model_dump()
|
||||
|
||||
@@ -166,10 +298,6 @@ class ConnectionManager:
|
||||
else:
|
||||
# Update cached status even without broadcast
|
||||
self._last_status = status_dict
|
||||
else:
|
||||
# Still update cache for when clients connect
|
||||
status = await get_status_func()
|
||||
self._last_status = status.model_dump()
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time as _time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional, Any
|
||||
|
||||
@@ -16,8 +18,10 @@ _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
|
||||
# Global storage for current album art (as bytes)
|
||||
_current_album_art_bytes: bytes | None = None
|
||||
|
||||
# Lock protecting _position_cache and _track_skip_pending from concurrent access
|
||||
_position_lock = threading.Lock()
|
||||
|
||||
# Global storage for position tracking
|
||||
import time as _time
|
||||
_position_cache = {
|
||||
"track_id": "",
|
||||
"base_position": 0.0,
|
||||
@@ -54,41 +58,100 @@ except ImportError:
|
||||
# Volume control imports
|
||||
PYCAW_AVAILABLE = False
|
||||
_volume_control = None
|
||||
_configured_device_name: str | None = None
|
||||
|
||||
try:
|
||||
from ctypes import cast, POINTER
|
||||
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
|
||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||
|
||||
def _init_volume_control():
|
||||
"""Initialize volume control interface."""
|
||||
global _volume_control
|
||||
if _volume_control is not None:
|
||||
return _volume_control
|
||||
import warnings
|
||||
# Suppress pycaw warnings about missing device properties
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="pycaw")
|
||||
|
||||
def _get_all_audio_devices() -> list[dict[str, str]]:
|
||||
"""Get list of all audio output devices."""
|
||||
devices = []
|
||||
try:
|
||||
devices = AudioUtilities.GetSpeakers()
|
||||
interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
||||
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
|
||||
return _volume_control
|
||||
except AttributeError:
|
||||
# Try accessing the underlying device
|
||||
# Use pycaw's GetAllDevices which handles property retrieval
|
||||
all_devices = AudioUtilities.GetAllDevices()
|
||||
for device in all_devices:
|
||||
# Only include render (output) devices with valid names
|
||||
# Render devices have IDs starting with {0.0.0
|
||||
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:
|
||||
devices = AudioUtilities.GetSpeakers()
|
||||
if hasattr(devices, '_dev'):
|
||||
interface = devices._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
||||
# Get all devices and find matching one
|
||||
all_devices = AudioUtilities.GetAllDevices()
|
||||
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))
|
||||
return _volume_control
|
||||
except Exception as e:
|
||||
logger.debug(f"Volume control init failed: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Volume control init error: {e}")
|
||||
logger.error(f"Volume control init error: {e}")
|
||||
return None
|
||||
|
||||
PYCAW_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
logger.warning(f"pycaw not available: {e}")
|
||||
|
||||
def _init_volume_control():
|
||||
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
|
||||
|
||||
WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
||||
@@ -165,6 +228,7 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
is_playing = result["state"] == "playing"
|
||||
current_title = result.get('title', '')
|
||||
|
||||
with _position_lock:
|
||||
# Check if track skip is pending and title changed
|
||||
skip_just_completed = False
|
||||
if _track_skip_pending["active"]:
|
||||
@@ -424,28 +488,46 @@ def _sync_seek(position: float) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def shutdown_executor() -> None:
|
||||
"""Shut down the WinRT thread pool executor."""
|
||||
_executor.shutdown(wait=False)
|
||||
|
||||
|
||||
class WindowsMediaController(MediaController):
|
||||
"""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:
|
||||
raise RuntimeError(
|
||||
"Windows media control requires winsdk, pycaw, and comtypes packages"
|
||||
)
|
||||
self._volume_interface = None
|
||||
self._volume_init_attempted = False
|
||||
self._audio_device = audio_device
|
||||
|
||||
def _get_volume_interface(self):
|
||||
"""Get the audio endpoint volume interface."""
|
||||
if not self._volume_init_attempted:
|
||||
self._volume_init_attempted = True
|
||||
self._volume_interface = _init_volume_control()
|
||||
self._volume_interface = _init_volume_control(self._audio_device)
|
||||
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:
|
||||
logger.warning("Volume control not available")
|
||||
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:
|
||||
"""Get current media playback status."""
|
||||
status = MediaStatus()
|
||||
@@ -530,7 +612,7 @@ class WindowsMediaController(MediaController):
|
||||
|
||||
result = await self._run_command("next")
|
||||
if result:
|
||||
# Set flag to force position to 0 until title changes
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
@@ -548,7 +630,7 @@ class WindowsMediaController(MediaController):
|
||||
|
||||
result = await self._run_command("previous")
|
||||
if result:
|
||||
# Set flag to force position to 0 until title changes
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
@@ -594,3 +676,24 @@ class WindowsMediaController(MediaController):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (Windows).
|
||||
|
||||
Uses os.startfile() to open the file with the default application.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the media file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: os.startfile(file_path))
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
3339
media_server/static/css/styles.css
Normal file
3339
media_server/static/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
10
media_server/static/icons/icon.svg
Normal file
10
media_server/static/icons/icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
|
||||
<path fill="white" d="M35 25 L35 75 L75 50 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
664
media_server/static/index.html
Normal file
664
media_server/static/index.html
Normal file
@@ -0,0 +1,664 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Media Server</title>
|
||||
<meta name="description" content="Remote media player control and file browser">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Media Server">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
<body class="loading-translations">
|
||||
<!-- Mini Player (sticky) -->
|
||||
<div class="mini-player hidden" id="mini-player">
|
||||
<div class="mini-player-info">
|
||||
<img id="mini-album-art" class="mini-album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
||||
<div class="mini-track-details">
|
||||
<div id="mini-track-title" class="mini-track-title">No media playing</div>
|
||||
<div id="mini-artist" class="mini-artist"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-controls">
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
||||
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mini-progress-container">
|
||||
<div class="mini-time-display">
|
||||
<span id="mini-current-time">0:00</span>
|
||||
<span id="mini-total-time">0:00</span>
|
||||
</div>
|
||||
<div class="mini-progress-bar" id="mini-progress-bar" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="mini-progress-fill" id="mini-progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-volume-container">
|
||||
<button class="mini-control-btn" onclick="toggleMute()" id="mini-btn-mute" title="Mute">
|
||||
<svg viewBox="0 0 24 24" id="mini-mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" id="mini-volume-slider" class="mini-volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
<div class="mini-volume-display" id="mini-volume-display">50%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Background -->
|
||||
<canvas id="bg-shader-canvas" class="bg-shader-canvas"></canvas>
|
||||
|
||||
<!-- Auth Modal -->
|
||||
<div id="auth-overlay" class="hidden">
|
||||
<div class="auth-modal">
|
||||
<h2 data-i18n="app.title">Media Server</h2>
|
||||
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p>
|
||||
<input type="text" id="token-input" data-i18n-placeholder="auth.placeholder" placeholder="Enter API Token" autocomplete="off">
|
||||
<button class="btn-connect" onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
<div class="help-text">
|
||||
<p data-i18n="auth.help">To get your token, run:</p>
|
||||
<code>media-server --show-token</code>
|
||||
</div>
|
||||
<div class="error-message" id="auth-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="status-dot" id="status-dot" aria-live="polite"></span>
|
||||
<span class="version-label" id="version-label"></span>
|
||||
</div>
|
||||
<div class="header-toolbar">
|
||||
<div id="headerLinks" class="header-links"></div>
|
||||
<div class="accent-picker">
|
||||
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<span class="accent-dot" id="accentDot"></span>
|
||||
</button>
|
||||
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
||||
</div>
|
||||
<button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
|
||||
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
|
||||
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
|
||||
</svg>
|
||||
<svg id="theme-icon-moon" viewBox="0 0 24 24">
|
||||
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language">
|
||||
<option value="en">EN</option>
|
||||
<option value="ru">RU</option>
|
||||
</select>
|
||||
<span class="header-toolbar-sep"></span>
|
||||
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Connection Banner -->
|
||||
<div class="connection-banner hidden" id="connectionBanner">
|
||||
<span id="connectionBannerText"></span>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar" id="tabBar" role="tablist">
|
||||
<div class="tab-indicator" id="tabIndicator"></div>
|
||||
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
|
||||
<span data-i18n="tab.player">Player</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/></svg>
|
||||
<span data-i18n="tab.display">Display</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
||||
<span data-i18n="tab.browser">Browser</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
|
||||
<span data-i18n="tab.quick_access">Quick Access</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<span data-i18n="tab.settings">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||
<div class="player-layout">
|
||||
<div class="album-art-container">
|
||||
<img id="album-art-glow" class="album-art-glow" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="" aria-hidden="true">
|
||||
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
||||
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="player-details">
|
||||
<div class="track-info">
|
||||
<div id="track-title" data-i18n="player.no_media">No media playing</div>
|
||||
<div id="artist"></div>
|
||||
<div id="album"></div>
|
||||
<div class="playback-state">
|
||||
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
<span id="playback-state" data-i18n="state.idle">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="time-display">
|
||||
<span id="current-time">0:00</span>
|
||||
<span id="total-time">0:00</span>
|
||||
</div>
|
||||
<div class="progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<svg viewBox="0 0 24 24" id="play-pause-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="volume-container">
|
||||
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<svg viewBox="0 0 24 24" id="mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
<div class="volume-display" id="volume-display">50%</div>
|
||||
</div>
|
||||
|
||||
<div class="source-info">
|
||||
<span class="source-label"><span class="source-icon" id="sourceIcon"></span><span id="source" data-i18n="player.unknown_source">Unknown</span></span>
|
||||
<div class="player-toggles">
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
|
||||
</button>
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Browser Section -->
|
||||
<div class="browser-container" data-tab-content="browser" role="tabpanel" id="panel-browser">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
|
||||
<!-- Browser Toolbar -->
|
||||
<div class="browser-toolbar" id="browserToolbar">
|
||||
<div class="browser-toolbar-left">
|
||||
<div class="view-toggle">
|
||||
<button class="view-toggle-btn active" id="viewGridBtn" onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" id="viewCompactBtn" onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" id="viewListBtn" onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<button class="browser-play-all-btn" id="playAllBtn" onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
|
||||
<svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." oninput="onBrowserSearch()">
|
||||
<button class="browser-search-clear" id="browserSearchClear" onclick="clearBrowserSearch()" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-toolbar-right">
|
||||
<label class="items-per-page-label">
|
||||
<span data-i18n="browser.items_per_page">Items per page:</span>
|
||||
<select id="itemsPerPageSelect" onchange="onItemsPerPageChanged()">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File/Folder Grid -->
|
||||
<div class="browser-grid" id="browserGrid">
|
||||
<div class="browser-empty empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
|
||||
<p data-i18n="browser.no_folder_selected">Select a folder to browse media files</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" id="browserPagination" style="display: none;">
|
||||
<button id="prevPage" onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
||||
<div class="pagination-center">
|
||||
<span data-i18n="browser.page">Page</span>
|
||||
<input type="number" id="pageInput" class="page-input" min="1" value="1" onchange="goToPage()">
|
||||
<span id="pageTotal">/ 1</span>
|
||||
</div>
|
||||
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts Section (Quick Actions) -->
|
||||
<div class="scripts-container" data-tab-content="quick-actions" role="tabpanel" id="panel-quick-actions">
|
||||
<div class="scripts-grid" id="scripts-grid">
|
||||
<div class="scripts-empty empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg>
|
||||
<p data-i18n="quick_access.no_items">No quick actions or links configured</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Section (Scripts, Callbacks, Links management) -->
|
||||
<div class="settings-container" data-tab-content="settings" role="tabpanel" id="panel-settings">
|
||||
<details class="settings-section" open id="audioDeviceSection" style="display: none;">
|
||||
<summary data-i18n="settings.section.audio">Audio</summary>
|
||||
<div class="settings-section-content">
|
||||
<p class="settings-section-description" data-i18n="settings.audio.description">
|
||||
Select which audio output device to capture for the visualizer.
|
||||
</p>
|
||||
<div class="audio-device-selector">
|
||||
<label>
|
||||
<span data-i18n="settings.audio.device">Loopback Device</span>
|
||||
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()">
|
||||
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="audio-device-status" id="audioDeviceStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section" open>
|
||||
<summary data-i18n="settings.section.scripts">Scripts</summary>
|
||||
<div class="settings-section-content">
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="scripts.table.name">Name</th>
|
||||
<th data-i18n="scripts.table.label">Label</th>
|
||||
<th data-i18n="scripts.table.command">Command</th>
|
||||
<th data-i18n="scripts.table.timeout">Timeout</th>
|
||||
<th data-i18n="scripts.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scriptsTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg>
|
||||
<p data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddScriptDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section" open>
|
||||
<summary data-i18n="settings.section.links">Links</summary>
|
||||
<div class="settings-section-content">
|
||||
<p class="settings-section-description" data-i18n="links.description">
|
||||
Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.
|
||||
</p>
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="links.table.name">Name</th>
|
||||
<th data-i18n="links.table.url">URL</th>
|
||||
<th data-i18n="links.table.label">Label</th>
|
||||
<th data-i18n="links.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="linksTableBody">
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<p data-i18n="links.empty">No links configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddLinkDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section" open>
|
||||
<summary data-i18n="settings.section.callbacks">Callbacks</summary>
|
||||
<div class="settings-section-content">
|
||||
<p class="settings-section-description" data-i18n="callbacks.description">
|
||||
Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
|
||||
</p>
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="callbacks.table.event">Event</th>
|
||||
<th data-i18n="callbacks.table.command">Command</th>
|
||||
<th data-i18n="callbacks.table.timeout">Timeout</th>
|
||||
<th data-i18n="callbacks.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="callbacksTableBody">
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
||||
<p>No callbacks configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddCallbackDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Display Control Section -->
|
||||
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
||||
<div class="display-monitors" id="displayMonitors">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<p data-i18n="display.loading">Loading monitors...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Script Dialog -->
|
||||
<dialog id="scriptDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
|
||||
</div>
|
||||
<form id="scriptForm" onsubmit="saveScript(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="scriptOriginalName">
|
||||
<input type="hidden" id="scriptIsEdit">
|
||||
|
||||
<label>
|
||||
<span data-i18n="scripts.field.name">Script Name *</span>
|
||||
<input type="text" id="scriptName" required pattern="[a-zA-Z0-9_]+"
|
||||
data-i18n-title="scripts.placeholder.name" title="Only letters, numbers, and underscores allowed" maxlength="64">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="scripts.field.label">Label</span>
|
||||
<input type="text" id="scriptLabel" data-i18n-placeholder="scripts.placeholder.label" placeholder="Human-readable name">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="scripts.field.command">Command *</span>
|
||||
<textarea id="scriptCommand" required rows="3" data-i18n-placeholder="scripts.placeholder.command" placeholder="e.g., shutdown /s /t 0"></textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="scripts.field.description">Description</span>
|
||||
<textarea id="scriptDescription" data-i18n-placeholder="scripts.placeholder.description" placeholder="What does this script do?"></textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="scripts.field.icon">Icon (MDI)</span>
|
||||
<div class="icon-input-wrapper">
|
||||
<input type="text" id="scriptIcon" data-i18n-placeholder="scripts.placeholder.icon" placeholder="e.g., mdi:power">
|
||||
<div class="icon-preview" id="scriptIconPreview"></div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="scripts.field.timeout">Timeout (seconds)</span>
|
||||
<input type="number" id="scriptTimeout" value="30" min="1" max="300">
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Add/Edit Callback Dialog -->
|
||||
<dialog id="callbackDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
|
||||
</div>
|
||||
<form id="callbackForm" onsubmit="saveCallback(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="callbackIsEdit">
|
||||
|
||||
<label>
|
||||
<span data-i18n="callbacks.field.event">Event *</span>
|
||||
<select id="callbackName" required>
|
||||
<option value="" data-i18n="callbacks.placeholder.event">Select event...</option>
|
||||
<option value="on_play" data-i18n="callbacks.event.on_play">on_play - After play succeeds</option>
|
||||
<option value="on_pause" data-i18n="callbacks.event.on_pause">on_pause - After pause succeeds</option>
|
||||
<option value="on_stop" data-i18n="callbacks.event.on_stop">on_stop - After stop succeeds</option>
|
||||
<option value="on_next" data-i18n="callbacks.event.on_next">on_next - After next track succeeds</option>
|
||||
<option value="on_previous" data-i18n="callbacks.event.on_previous">on_previous - After previous track succeeds</option>
|
||||
<option value="on_volume" data-i18n="callbacks.event.on_volume">on_volume - After volume change</option>
|
||||
<option value="on_mute" data-i18n="callbacks.event.on_mute">on_mute - After mute toggle</option>
|
||||
<option value="on_seek" data-i18n="callbacks.event.on_seek">on_seek - After seek succeeds</option>
|
||||
<option value="on_turn_on" data-i18n="callbacks.event.on_turn_on">on_turn_on - Callback-only action</option>
|
||||
<option value="on_turn_off" data-i18n="callbacks.event.on_turn_off">on_turn_off - Callback-only action</option>
|
||||
<option value="on_toggle" data-i18n="callbacks.event.on_toggle">on_toggle - Callback-only action</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="callbacks.field.command">Command *</span>
|
||||
<textarea id="callbackCommand" required rows="3" data-i18n-placeholder="callbacks.placeholder.command" placeholder="e.g., shutdown /s /t 0"></textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="callbacks.field.timeout">Timeout (seconds)</span>
|
||||
<input type="number" id="callbackTimeout" value="30" min="1" max="300">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="callbacks.field.workdir">Working Directory</span>
|
||||
<input type="text" id="callbackWorkingDir" data-i18n-placeholder="callbacks.placeholder.workdir" placeholder="Optional">
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Add/Edit Link Dialog -->
|
||||
<dialog id="linkDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
|
||||
</div>
|
||||
<form id="linkForm" onsubmit="saveLink(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="linkOriginalName">
|
||||
<input type="hidden" id="linkIsEdit">
|
||||
|
||||
<label>
|
||||
<span data-i18n="links.field.name">Link Name *</span>
|
||||
<input type="text" id="linkName" required pattern="[a-zA-Z0-9_]+"
|
||||
data-i18n-title="links.placeholder.name" title="Only letters, numbers, and underscores allowed" maxlength="64">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="links.field.url">URL *</span>
|
||||
<input type="url" id="linkUrl" required data-i18n-placeholder="links.placeholder.url" placeholder="https://example.com">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="links.field.icon">Icon (MDI)</span>
|
||||
<div class="icon-input-wrapper">
|
||||
<input type="text" id="linkIcon" data-i18n-placeholder="links.placeholder.icon" placeholder="mdi:link">
|
||||
<div class="icon-preview" id="linkIconPreview"></div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="links.field.label">Label</span>
|
||||
<input type="text" id="linkLabel" data-i18n-placeholder="links.placeholder.label" placeholder="Tooltip text">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="links.field.description">Description</span>
|
||||
<textarea id="linkDescription" data-i18n-placeholder="links.placeholder.description" placeholder="What does this link point to?"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Execution Result Dialog -->
|
||||
<dialog id="executionDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="executionDialogTitle" data-i18n="scripts.execution.title">Execution Result</h3>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="execution-status" id="executionStatus"></div>
|
||||
<div class="result-section" id="outputSection" style="display: none;">
|
||||
<h4 data-i18n="scripts.execution.output">Output</h4>
|
||||
<div class="execution-result">
|
||||
<pre id="executionOutput"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-section" id="errorSection" style="display: none;">
|
||||
<h4 data-i18n="scripts.execution.error_output">Error Output</h4>
|
||||
<div class="execution-result">
|
||||
<pre id="executionError"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Folder Management Dialog -->
|
||||
<dialog id="folderDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
|
||||
</div>
|
||||
<form id="folderForm" onsubmit="saveFolder(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="folderIsEdit">
|
||||
<input type="hidden" id="folderOriginalId">
|
||||
|
||||
<label>
|
||||
<span data-i18n="browser.folder_dialog.folder_id">Folder ID *</span>
|
||||
<input type="text" id="folderId" required pattern="[a-zA-Z0-9_]+"
|
||||
data-i18n-title="browser.folder_dialog.folder_id_help" title="Alphanumeric and underscore only" maxlength="32">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="browser.folder_dialog.label">Label *</span>
|
||||
<input type="text" id="folderLabel" required data-i18n-placeholder="browser.folder_dialog.label_help" placeholder="Display name">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="browser.folder_dialog.path">Path *</span>
|
||||
<input type="text" id="folderPath" required data-i18n-placeholder="browser.folder_dialog.path_help" placeholder="C:\Users\YourName\Music">
|
||||
</label>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="folderEnabled" checked style="width: auto; margin: 0;">
|
||||
<span data-i18n="browser.folder_dialog.enabled">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<dialog id="confirmDialog" class="confirm-dialog">
|
||||
<p id="confirmDialogMessage"></p>
|
||||
<div class="confirm-dialog-actions">
|
||||
<button type="button" class="btn-cancel" id="confirmDialogCancel" data-i18n="dialog.cancel">Cancel</button>
|
||||
<button type="button" class="btn-danger" id="confirmDialogConfirm" data-i18n="dialog.confirm">Confirm</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div>
|
||||
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
|
||||
<span class="separator">•</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/core.js"></script>
|
||||
<script src="/static/js/player.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/scripts.js"></script>
|
||||
<script src="/static/js/callbacks.js"></script>
|
||||
<script src="/static/js/browser.js"></script>
|
||||
<script src="/static/js/links.js"></script>
|
||||
<script src="/static/js/background.js"></script>
|
||||
<script src="/static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
314
media_server/static/js/background.js
Normal file
314
media_server/static/js/background.js
Normal file
@@ -0,0 +1,314 @@
|
||||
// ============================================================
|
||||
// Background: WebGL shader-based dynamic background
|
||||
// ============================================================
|
||||
|
||||
let bgCanvas = null;
|
||||
let bgGL = null;
|
||||
let bgProgram = null;
|
||||
let bgUniforms = null; // Cached uniform locations
|
||||
let bgAnimFrame = null;
|
||||
let bgEnabled = localStorage.getItem('dynamicBackground') === 'true';
|
||||
let bgStartTime = 0;
|
||||
let bgSmoothedBands = new Float32Array(16);
|
||||
let bgSmoothedBass = 0;
|
||||
let bgAccentRGB = [0.114, 0.725, 0.329]; // Cached accent color (default green)
|
||||
let bgBgColorRGB = [0.071, 0.071, 0.071]; // Cached page background (#121212)
|
||||
|
||||
const BG_BAND_COUNT = 16;
|
||||
const BG_SMOOTHING = 0.12;
|
||||
|
||||
// ---- Shaders ----
|
||||
|
||||
const BG_VERT_SRC = `
|
||||
attribute vec2 a_position;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BG_FRAG_SRC = `
|
||||
precision mediump float;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time;
|
||||
uniform float u_bass;
|
||||
uniform float u_bands[16];
|
||||
uniform vec3 u_accent;
|
||||
uniform vec3 u_bgColor;
|
||||
|
||||
// Smooth noise
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / u_resolution;
|
||||
float aspect = u_resolution.x / u_resolution.y;
|
||||
|
||||
// Center coordinates for radial effects
|
||||
vec2 center = (uv - 0.5) * vec2(aspect, 1.0);
|
||||
float dist = length(center);
|
||||
float angle = atan(center.y, center.x);
|
||||
|
||||
// Slow base animation
|
||||
float t = u_time * 0.15;
|
||||
|
||||
// === Layer 1: Flowing wave field ===
|
||||
float waves = 0.0;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
float fi = float(i);
|
||||
float freq = 1.5 + fi * 0.8;
|
||||
float speed = t * (0.6 + fi * 0.15);
|
||||
// Sample a band for this wave layer
|
||||
int bandIdx = i * 3;
|
||||
float bandVal = 0.0;
|
||||
// Manual indexing (GLSL ES doesn't allow variable array index in some drivers)
|
||||
for (int j = 0; j < 16; j++) {
|
||||
if (j == bandIdx) bandVal = u_bands[j];
|
||||
}
|
||||
float amp = 0.015 + bandVal * 0.06;
|
||||
waves += amp * sin(uv.x * freq * 6.2832 + speed + sin(uv.y * 3.0 + t) * 2.0);
|
||||
waves += amp * 0.5 * sin(uv.y * freq * 4.0 - speed * 0.7 + cos(uv.x * 2.5 + t) * 1.5);
|
||||
}
|
||||
|
||||
// === Layer 2: Radial pulse (bass-driven) ===
|
||||
float pulse = smoothstep(0.6 + u_bass * 0.3, 0.0, dist) * (0.08 + u_bass * 0.15);
|
||||
|
||||
// === Layer 3: Frequency ring arcs ===
|
||||
float rings = 0.0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
float fi = float(i);
|
||||
float bandVal = 0.0;
|
||||
for (int j = 0; j < 16; j++) {
|
||||
if (j == i * 2) bandVal = u_bands[j];
|
||||
}
|
||||
float radius = 0.15 + fi * 0.1;
|
||||
float ringWidth = 0.008 + bandVal * 0.025;
|
||||
float ring = smoothstep(ringWidth, 0.0, abs(dist - radius - bandVal * 0.05));
|
||||
// Fade ring by angle sector for variety
|
||||
float angleFade = 0.5 + 0.5 * sin(angle * (2.0 + fi) + t * (1.0 + fi * 0.3));
|
||||
rings += ring * angleFade * (0.3 + bandVal * 0.7);
|
||||
}
|
||||
|
||||
// === Layer 4: Subtle noise texture ===
|
||||
float n = noise(uv * 4.0 + t * 0.5) * 0.03;
|
||||
|
||||
// Combine layers
|
||||
float intensity = waves + pulse + rings * 0.5 + n;
|
||||
|
||||
// Color: accent color with varying brightness
|
||||
vec3 col = u_accent * intensity;
|
||||
|
||||
// Subtle secondary hue shift for depth
|
||||
vec3 shifted = u_accent.gbr; // Rotated accent
|
||||
col += shifted * rings * 0.15;
|
||||
|
||||
// Vignette
|
||||
float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
|
||||
col *= vignette;
|
||||
|
||||
// Blend over page background
|
||||
col = clamp(col, 0.0, 1.0);
|
||||
float colBright = (col.r + col.g + col.b) / 3.0;
|
||||
float bgLum = dot(u_bgColor, vec3(0.299, 0.587, 0.114));
|
||||
// Dark bg: add accent light. Light bg: tint white toward accent via multiply.
|
||||
vec3 darkResult = u_bgColor + col;
|
||||
vec3 lightResult = u_bgColor * mix(vec3(1.0), u_accent, colBright * 2.0);
|
||||
vec3 finalColor = clamp(mix(darkResult, lightResult, bgLum), 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- WebGL setup ----
|
||||
|
||||
function initBackgroundGL() {
|
||||
bgCanvas = document.getElementById('bg-shader-canvas');
|
||||
if (!bgCanvas) return false;
|
||||
|
||||
bgGL = bgCanvas.getContext('webgl', { alpha: false, antialias: false, depth: false, stencil: false });
|
||||
if (!bgGL) {
|
||||
console.warn('WebGL not available for background shader');
|
||||
return false;
|
||||
}
|
||||
|
||||
const gl = bgGL;
|
||||
|
||||
// Compile shaders
|
||||
const vs = gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.shaderSource(vs, BG_VERT_SRC);
|
||||
gl.compileShader(vs);
|
||||
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
|
||||
console.error('BG vertex shader:', gl.getShaderInfoLog(vs));
|
||||
return false;
|
||||
}
|
||||
|
||||
const fs = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
gl.shaderSource(fs, BG_FRAG_SRC);
|
||||
gl.compileShader(fs);
|
||||
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
|
||||
console.error('BG fragment shader:', gl.getShaderInfoLog(fs));
|
||||
return false;
|
||||
}
|
||||
|
||||
bgProgram = gl.createProgram();
|
||||
gl.attachShader(bgProgram, vs);
|
||||
gl.attachShader(bgProgram, fs);
|
||||
gl.linkProgram(bgProgram);
|
||||
if (!gl.getProgramParameter(bgProgram, gl.LINK_STATUS)) {
|
||||
console.error('BG program link:', gl.getProgramInfoLog(bgProgram));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fullscreen quad
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1,
|
||||
-1, 1, 1, -1, 1, 1
|
||||
]), gl.STATIC_DRAW);
|
||||
|
||||
const aPos = gl.getAttribLocation(bgProgram, 'a_position');
|
||||
gl.enableVertexAttribArray(aPos);
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.useProgram(bgProgram);
|
||||
|
||||
// Cache uniform locations once (avoids per-frame lookups)
|
||||
bgUniforms = {
|
||||
resolution: gl.getUniformLocation(bgProgram, 'u_resolution'),
|
||||
time: gl.getUniformLocation(bgProgram, 'u_time'),
|
||||
bass: gl.getUniformLocation(bgProgram, 'u_bass'),
|
||||
bands: gl.getUniformLocation(bgProgram, 'u_bands'),
|
||||
accent: gl.getUniformLocation(bgProgram, 'u_accent'),
|
||||
bgColor: gl.getUniformLocation(bgProgram, 'u_bgColor'),
|
||||
};
|
||||
|
||||
bgStartTime = performance.now() / 1000;
|
||||
updateBackgroundColors();
|
||||
resizeBackgroundCanvas();
|
||||
window.addEventListener('resize', resizeBackgroundCanvas);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resizeBackgroundCanvas() {
|
||||
if (!bgCanvas) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5); // Cap DPR for performance
|
||||
const w = Math.floor(window.innerWidth * dpr);
|
||||
const h = Math.floor(window.innerHeight * dpr);
|
||||
if (bgCanvas.width !== w || bgCanvas.height !== h) {
|
||||
bgCanvas.width = w;
|
||||
bgCanvas.height = h;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
|
||||
|
||||
function updateBackgroundColors() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accentHex = style.getPropertyValue('--accent').trim();
|
||||
if (accentHex && accentHex.length >= 7) {
|
||||
bgAccentRGB[0] = parseInt(accentHex.slice(1, 3), 16) / 255;
|
||||
bgAccentRGB[1] = parseInt(accentHex.slice(3, 5), 16) / 255;
|
||||
bgAccentRGB[2] = parseInt(accentHex.slice(5, 7), 16) / 255;
|
||||
}
|
||||
const bgHex = style.getPropertyValue('--bg-primary').trim();
|
||||
if (bgHex && bgHex.length >= 7) {
|
||||
bgBgColorRGB[0] = parseInt(bgHex.slice(1, 3), 16) / 255;
|
||||
bgBgColorRGB[1] = parseInt(bgHex.slice(3, 5), 16) / 255;
|
||||
bgBgColorRGB[2] = parseInt(bgHex.slice(5, 7), 16) / 255;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Render loop ----
|
||||
|
||||
function renderBackgroundFrame() {
|
||||
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
||||
|
||||
const gl = bgGL;
|
||||
if (!gl || !bgUniforms) return;
|
||||
|
||||
resizeBackgroundCanvas();
|
||||
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
|
||||
|
||||
const time = performance.now() / 1000 - bgStartTime;
|
||||
|
||||
// Smooth audio data from the global frequencyData (shared with visualizer)
|
||||
if (typeof frequencyData !== 'undefined' && frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
const idx = Math.min(i * step, bins.length - 1);
|
||||
const target = bins[idx] || 0;
|
||||
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
|
||||
}
|
||||
const targetBass = frequencyData.bass || 0;
|
||||
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||||
} else {
|
||||
// Gentle decay when no audio
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
bgSmoothedBands[i] *= 0.95;
|
||||
}
|
||||
bgSmoothedBass *= 0.95;
|
||||
}
|
||||
|
||||
// Set uniforms (locations cached at init, colors cached on change)
|
||||
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
|
||||
gl.uniform1f(bgUniforms.time, time);
|
||||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||||
gl.uniform3f(bgUniforms.accent, bgAccentRGB[0], bgAccentRGB[1], bgAccentRGB[2]);
|
||||
gl.uniform3f(bgUniforms.bgColor, bgBgColorRGB[0], bgBgColorRGB[1], bgBgColorRGB[2]);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
function startBackground() {
|
||||
if (bgAnimFrame) return;
|
||||
if (!bgGL && !initBackgroundGL()) return;
|
||||
bgCanvas.classList.add('visible');
|
||||
document.body.classList.add('dynamic-bg-active');
|
||||
renderBackgroundFrame();
|
||||
}
|
||||
|
||||
function stopBackground() {
|
||||
if (bgAnimFrame) {
|
||||
cancelAnimationFrame(bgAnimFrame);
|
||||
bgAnimFrame = null;
|
||||
}
|
||||
if (bgCanvas) {
|
||||
bgCanvas.classList.remove('visible');
|
||||
}
|
||||
document.body.classList.remove('dynamic-bg-active');
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
function toggleDynamicBackground() {
|
||||
bgEnabled = !bgEnabled;
|
||||
localStorage.setItem('dynamicBackground', bgEnabled);
|
||||
applyDynamicBackground();
|
||||
}
|
||||
|
||||
function applyDynamicBackground() {
|
||||
const btn = document.getElementById('bgToggle');
|
||||
if (bgEnabled) {
|
||||
startBackground();
|
||||
if (btn) btn.classList.add('active');
|
||||
} else {
|
||||
stopBackground();
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
882
media_server/static/js/browser.js
Normal file
882
media_server/static/js/browser.js
Normal file
@@ -0,0 +1,882 @@
|
||||
// ============================================================
|
||||
// Media Browser: Navigation, rendering, search, pagination
|
||||
// ============================================================
|
||||
|
||||
// Browser state
|
||||
let currentFolderId = null;
|
||||
let currentPath = '';
|
||||
let currentOffset = 0;
|
||||
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
|
||||
let totalItems = 0;
|
||||
let mediaFolders = {};
|
||||
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
let cachedItems = null;
|
||||
let browserSearchTerm = '';
|
||||
let browserSearchTimer = null;
|
||||
const thumbnailCache = new Map();
|
||||
const THUMBNAIL_CACHE_MAX = 200;
|
||||
|
||||
// Load media folders on page load
|
||||
async function loadMediaFolders() {
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) {
|
||||
console.error('No API token found');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/browser/folders', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load folders');
|
||||
|
||||
mediaFolders = await response.json();
|
||||
|
||||
// Load last browsed path or show root folder list
|
||||
loadLastBrowserPath();
|
||||
} catch (error) {
|
||||
console.error('Error loading media folders:', error);
|
||||
showToast(t('browser.error_loading_folders'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showRootFolders() {
|
||||
currentFolderId = '';
|
||||
currentPath = '';
|
||||
currentOffset = 0;
|
||||
cachedItems = null;
|
||||
|
||||
// Hide search at root level
|
||||
showBrowserSearch(false);
|
||||
|
||||
// Render breadcrumb with just "Home" (not clickable at root)
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
breadcrumb.innerHTML = '';
|
||||
const root = document.createElement('span');
|
||||
root.className = 'breadcrumb-item breadcrumb-home';
|
||||
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
breadcrumb.appendChild(root);
|
||||
|
||||
// Hide play all button and pagination
|
||||
document.getElementById('playAllBtn').style.display = 'none';
|
||||
document.getElementById('browserPagination').style.display = 'none';
|
||||
|
||||
// Render folders as grid cards
|
||||
const container = document.getElementById('browserGrid');
|
||||
revokeBlobUrls(container);
|
||||
if (viewMode === 'list') {
|
||||
container.className = 'browser-list';
|
||||
} else if (viewMode === 'compact') {
|
||||
container.className = 'browser-grid browser-grid-compact';
|
||||
} else {
|
||||
container.className = 'browser-grid';
|
||||
}
|
||||
container.innerHTML = '';
|
||||
|
||||
Object.entries(mediaFolders).forEach(([id, folder]) => {
|
||||
if (!folder.enabled) return;
|
||||
|
||||
if (viewMode === 'list') {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'browser-list-item';
|
||||
row.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
row.innerHTML = `
|
||||
<div class="browser-list-icon">\u{1F4C1}</div>
|
||||
<div class="browser-list-name">${folder.label}</div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
} else {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'browser-item';
|
||||
card.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
card.innerHTML = `
|
||||
<div class="browser-thumb-wrapper">
|
||||
<div class="browser-icon">\u{1F4C1}</div>
|
||||
</div>
|
||||
<div class="browser-item-info">
|
||||
<div class="browser-item-name">${folder.label}</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function browsePath(folderId, path, offset = 0, nocache = false) {
|
||||
// Clear search when navigating
|
||||
showBrowserSearch(false);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) {
|
||||
console.error('No API token found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading spinner
|
||||
const container = document.getElementById('browserGrid');
|
||||
container.className = 'browser-grid';
|
||||
container.innerHTML = '<div class="browser-loading"><div class="loading-spinner"></div></div>';
|
||||
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
let url = `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`;
|
||||
if (nocache) url += '&nocache=true';
|
||||
const response = await fetch(
|
||||
url,
|
||||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMsg = 'Failed to browse path';
|
||||
if (response.status === 503) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
errorMsg = errorData.detail || 'Folder is temporarily unavailable (network share not accessible)';
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
currentPath = data.current_path;
|
||||
currentOffset = offset;
|
||||
totalItems = data.total;
|
||||
|
||||
cachedItems = data.items;
|
||||
renderBreadcrumbs(data.current_path, data.parent_path);
|
||||
renderBrowserItems(cachedItems);
|
||||
renderPagination();
|
||||
|
||||
// Show search bar when inside a folder
|
||||
showBrowserSearch(true);
|
||||
|
||||
// Show/hide Play All button based on whether media items exist
|
||||
const hasMedia = data.items.some(item => item.is_media);
|
||||
document.getElementById('playAllBtn').style.display = hasMedia ? '' : 'none';
|
||||
|
||||
// Save last path
|
||||
saveLastBrowserPath(folderId, currentPath);
|
||||
} catch (error) {
|
||||
console.error('Error browsing path:', error);
|
||||
const errorMsg = error.message || t('browser.error_loading');
|
||||
showToast(errorMsg, 'error');
|
||||
clearBrowserGrid();
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreadcrumbs(currentPath, parentPath) {
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
breadcrumb.innerHTML = '';
|
||||
|
||||
const parts = (currentPath || '').split('/').filter(p => p);
|
||||
let path = '/';
|
||||
|
||||
// Home link (back to folder list)
|
||||
const home = document.createElement('span');
|
||||
home.className = 'breadcrumb-item breadcrumb-home';
|
||||
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
home.onclick = () => showRootFolders();
|
||||
breadcrumb.appendChild(home);
|
||||
|
||||
// Separator + Folder name
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'breadcrumb-separator';
|
||||
sep.textContent = '\u203A';
|
||||
breadcrumb.appendChild(sep);
|
||||
|
||||
const folderItem = document.createElement('span');
|
||||
folderItem.className = 'breadcrumb-item';
|
||||
folderItem.textContent = mediaFolders[currentFolderId]?.label || 'Root';
|
||||
if (parts.length > 0) {
|
||||
folderItem.onclick = () => browsePath(currentFolderId, '');
|
||||
}
|
||||
breadcrumb.appendChild(folderItem);
|
||||
|
||||
// Path parts
|
||||
parts.forEach((part, index) => {
|
||||
// Separator
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'breadcrumb-separator';
|
||||
separator.textContent = '\u203A';
|
||||
breadcrumb.appendChild(separator);
|
||||
|
||||
// Part
|
||||
path += (path === '/' ? '' : '/') + part;
|
||||
const item = document.createElement('span');
|
||||
item.className = 'breadcrumb-item';
|
||||
item.textContent = part;
|
||||
const itemPath = path;
|
||||
item.onclick = () => browsePath(currentFolderId, itemPath);
|
||||
breadcrumb.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function revokeBlobUrls(container) {
|
||||
const cachedUrls = new Set(thumbnailCache.values());
|
||||
container.querySelectorAll('img[src^="blob:"]').forEach(img => {
|
||||
// Don't revoke URLs managed by the thumbnail cache
|
||||
if (!cachedUrls.has(img.src)) {
|
||||
URL.revokeObjectURL(img.src);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderBrowserItems(items) {
|
||||
const container = document.getElementById('browserGrid');
|
||||
revokeBlobUrls(container);
|
||||
// Switch container class based on view mode
|
||||
if (viewMode === 'list') {
|
||||
container.className = 'browser-list';
|
||||
renderBrowserList(items, container);
|
||||
} else if (viewMode === 'compact') {
|
||||
container.className = 'browser-grid browser-grid-compact';
|
||||
renderBrowserGrid(items, container);
|
||||
} else {
|
||||
container.className = 'browser-grid';
|
||||
renderBrowserGrid(items, container);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBrowserList(items, container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'browser-list-item';
|
||||
row.style.setProperty('--item-index', Math.min(idx, 20));
|
||||
row.dataset.name = item.name;
|
||||
row.dataset.type = item.type;
|
||||
|
||||
// Icon (small) with play overlay
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'browser-list-icon';
|
||||
|
||||
if (item.is_media && item.type === 'audio') {
|
||||
const thumbnail = document.createElement('img');
|
||||
thumbnail.className = 'browser-list-thumbnail loading';
|
||||
thumbnail.alt = item.name;
|
||||
icon.appendChild(thumbnail);
|
||||
loadThumbnail(thumbnail, item.name);
|
||||
} else {
|
||||
icon.textContent = getFileIcon(item.type);
|
||||
}
|
||||
|
||||
if (item.is_media) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'browser-list-play-overlay';
|
||||
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
|
||||
icon.appendChild(overlay);
|
||||
}
|
||||
row.appendChild(icon);
|
||||
|
||||
// Name (show media title if available)
|
||||
const name = document.createElement('div');
|
||||
name.className = 'browser-list-name';
|
||||
name.textContent = item.title || item.name;
|
||||
row.appendChild(name);
|
||||
|
||||
// Bitrate
|
||||
const br = document.createElement('div');
|
||||
br.className = 'browser-list-bitrate';
|
||||
br.textContent = formatBitrate(item.bitrate) || '';
|
||||
row.appendChild(br);
|
||||
|
||||
// Duration
|
||||
const dur = document.createElement('div');
|
||||
dur.className = 'browser-list-duration';
|
||||
dur.textContent = formatDuration(item.duration) || '';
|
||||
row.appendChild(dur);
|
||||
|
||||
// Size
|
||||
const size = document.createElement('div');
|
||||
size.className = 'browser-list-size';
|
||||
size.textContent = (item.size !== null && item.type !== 'folder') ? formatFileSize(item.size) : '';
|
||||
row.appendChild(size);
|
||||
|
||||
// Download button
|
||||
if (item.is_media) {
|
||||
row.appendChild(createDownloadBtn(item.name, 'browser-list-download'));
|
||||
} else {
|
||||
row.appendChild(document.createElement('div'));
|
||||
}
|
||||
|
||||
// Tooltip: show filename when title is displayed, or when name is ellipsed
|
||||
row.addEventListener('mouseenter', () => {
|
||||
if (item.title || name.scrollWidth > name.clientWidth) {
|
||||
row.title = item.name;
|
||||
} else {
|
||||
row.title = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Single click: play media or navigate folder
|
||||
row.onclick = () => {
|
||||
if (item.type === 'folder') {
|
||||
const newPath = currentPath === '/'
|
||||
? '/' + item.name
|
||||
: currentPath + '/' + item.name;
|
||||
browsePath(currentFolderId, newPath);
|
||||
} else if (item.is_media) {
|
||||
playMediaFile(item.name);
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBrowserGrid(items, container) {
|
||||
container = container || document.getElementById('browserGrid');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'browser-item';
|
||||
div.style.setProperty('--item-index', Math.min(idx, 20));
|
||||
div.dataset.name = item.name;
|
||||
div.dataset.type = item.type;
|
||||
|
||||
// Type badge
|
||||
if (item.type !== 'folder') {
|
||||
const typeBadge = document.createElement('div');
|
||||
typeBadge.className = `browser-item-type ${item.type}`;
|
||||
typeBadge.innerHTML = getTypeBadgeIcon(item.type);
|
||||
div.appendChild(typeBadge);
|
||||
}
|
||||
|
||||
// Thumbnail wrapper (for play overlay)
|
||||
const thumbWrapper = document.createElement('div');
|
||||
thumbWrapper.className = 'browser-thumb-wrapper';
|
||||
|
||||
// Thumbnail or icon
|
||||
if (item.is_media && item.type === 'audio') {
|
||||
const thumbnail = document.createElement('img');
|
||||
thumbnail.className = 'browser-thumbnail loading';
|
||||
thumbnail.alt = item.name;
|
||||
thumbWrapper.appendChild(thumbnail);
|
||||
|
||||
// Lazy load thumbnail
|
||||
loadThumbnail(thumbnail, item.name);
|
||||
} else {
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'browser-icon';
|
||||
icon.textContent = getFileIcon(item.type);
|
||||
thumbWrapper.appendChild(icon);
|
||||
}
|
||||
|
||||
// Play overlay for media files
|
||||
if (item.is_media) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'browser-play-overlay';
|
||||
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
|
||||
thumbWrapper.appendChild(overlay);
|
||||
}
|
||||
|
||||
div.appendChild(thumbWrapper);
|
||||
|
||||
// Info
|
||||
const info = document.createElement('div');
|
||||
info.className = 'browser-item-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'browser-item-name';
|
||||
name.textContent = item.title || item.name;
|
||||
info.appendChild(name);
|
||||
|
||||
if (item.type !== 'folder') {
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'browser-item-meta';
|
||||
const parts = [];
|
||||
const duration = formatDuration(item.duration);
|
||||
if (duration) parts.push(duration);
|
||||
const bitrate = formatBitrate(item.bitrate);
|
||||
if (bitrate) parts.push(bitrate);
|
||||
if (item.size !== null) parts.push(formatFileSize(item.size));
|
||||
meta.textContent = parts.join(' \u00B7 ');
|
||||
if (parts.length) info.appendChild(meta);
|
||||
}
|
||||
|
||||
div.appendChild(info);
|
||||
|
||||
// Tooltip: show filename when title is displayed, or when name is ellipsed
|
||||
div.addEventListener('mouseenter', () => {
|
||||
if (item.title || name.scrollWidth > name.clientWidth || name.scrollHeight > name.clientHeight) {
|
||||
div.title = item.name;
|
||||
} else {
|
||||
div.title = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Single click: play media or navigate folder
|
||||
div.onclick = () => {
|
||||
if (item.type === 'folder') {
|
||||
const newPath = currentPath === '/'
|
||||
? '/' + item.name
|
||||
: currentPath + '/' + item.name;
|
||||
browsePath(currentFolderId, newPath);
|
||||
} else if (item.is_media) {
|
||||
playMediaFile(item.name);
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function getTypeBadgeIcon(type) {
|
||||
const svgs = {
|
||||
'audio': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>',
|
||||
'video': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
|
||||
};
|
||||
return svgs[type] || '';
|
||||
}
|
||||
|
||||
function getFileIcon(type) {
|
||||
const icons = {
|
||||
'folder': '\u{1F4C1}',
|
||||
'audio': '\u{1F3B5}',
|
||||
'video': '\u{1F3AC}',
|
||||
'other': '\u{1F4C4}'
|
||||
};
|
||||
return icons[type] || icons.other;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null || seconds <= 0) return null;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatBitrate(bps) {
|
||||
if (bps == null || bps <= 0) return null;
|
||||
return Math.round(bps / 1000) + ' kbps';
|
||||
}
|
||||
|
||||
async function loadThumbnail(imgElement, fileName) {
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) {
|
||||
console.error('No API token found');
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
|
||||
// Check cache first
|
||||
if (thumbnailCache.has(absolutePath)) {
|
||||
const cachedUrl = thumbnailCache.get(absolutePath);
|
||||
imgElement.onload = () => {
|
||||
imgElement.classList.remove('loading');
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
imgElement.src = cachedUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(absolutePath);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
|
||||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
thumbnailCache.set(absolutePath, url);
|
||||
|
||||
// Evict oldest entries when cache exceeds limit
|
||||
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
|
||||
const oldest = thumbnailCache.keys().next().value;
|
||||
URL.revokeObjectURL(thumbnailCache.get(oldest));
|
||||
thumbnailCache.delete(oldest);
|
||||
}
|
||||
|
||||
// Wait for image to actually load before showing it
|
||||
imgElement.onload = () => {
|
||||
imgElement.classList.remove('loading');
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
|
||||
// Revoke previous blob URL if not managed by cache
|
||||
// (Cache is keyed by path, so check values)
|
||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||
let isCached = false;
|
||||
for (const url of thumbnailCache.values()) {
|
||||
if (url === imgElement.src) { isCached = true; break; }
|
||||
}
|
||||
if (!isCached) URL.revokeObjectURL(imgElement.src);
|
||||
}
|
||||
imgElement.src = url;
|
||||
} else {
|
||||
// Fallback to icon (204 = no thumbnail available)
|
||||
const parent = imgElement.parentElement;
|
||||
const isList = parent.classList.contains('browser-list-icon');
|
||||
imgElement.remove();
|
||||
if (isList) {
|
||||
parent.textContent = '\u{1F3B5}';
|
||||
} else {
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'browser-icon';
|
||||
icon.textContent = '\u{1F3B5}';
|
||||
parent.insertBefore(icon, parent.firstChild);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading thumbnail:', error);
|
||||
imgElement.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
function buildAbsolutePath(folderId, relativePath, fileName) {
|
||||
const folderPath = mediaFolders[folderId].path;
|
||||
// Detect separator from folder path
|
||||
const sep = folderPath.includes('/') ? '/' : '\\';
|
||||
const fullRelative = relativePath === '/'
|
||||
? sep + fileName
|
||||
: relativePath.replace(/[/\\]/g, sep) + sep + fileName;
|
||||
return folderPath + fullRelative;
|
||||
}
|
||||
|
||||
let playInProgress = false;
|
||||
|
||||
async function playMediaFile(fileName) {
|
||||
if (playInProgress) return;
|
||||
playInProgress = true;
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) {
|
||||
console.error('No API token found');
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
|
||||
const response = await fetch('/api/browser/play', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path: absolutePath })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to play file');
|
||||
|
||||
showToast(t('browser.play_success', { filename: fileName }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Error playing file:', error);
|
||||
showToast(t('browser.play_error'), 'error');
|
||||
} finally {
|
||||
playInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function playAllFolder() {
|
||||
if (playInProgress) return;
|
||||
playInProgress = true;
|
||||
const btn = document.getElementById('playAllBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token || !currentFolderId) return;
|
||||
|
||||
const response = await fetch('/api/browser/play-folder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ folder_id: currentFolderId, path: currentPath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to play folder');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showToast(t('browser.play_all_success', { count: data.count }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Error playing folder:', error);
|
||||
showToast(t('browser.play_all_error'), 'error');
|
||||
} finally {
|
||||
playInProgress = false;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(fileName, event) {
|
||||
if (event) event.stopPropagation();
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
|
||||
const fullPath = currentPath === '/'
|
||||
? '/' + fileName
|
||||
: currentPath + '/' + fileName;
|
||||
const encodedPath = encodeURIComponent(fullPath);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`,
|
||||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||||
);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
showToast(t('browser.download_error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadBtn(fileName, cssClass) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = cssClass;
|
||||
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>';
|
||||
btn.title = t('browser.download');
|
||||
btn.onclick = (e) => downloadFile(fileName, e);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
const pagination = document.getElementById('browserPagination');
|
||||
const prevBtn = document.getElementById('prevPage');
|
||||
const nextBtn = document.getElementById('nextPage');
|
||||
const pageInput = document.getElementById('pageInput');
|
||||
const pageTotal = document.getElementById('pageTotal');
|
||||
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
|
||||
|
||||
if (totalPages <= 1) {
|
||||
pagination.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
pagination.style.display = 'flex';
|
||||
pageInput.value = currentPage;
|
||||
pageInput.max = totalPages;
|
||||
pageTotal.textContent = `/ ${totalPages}`;
|
||||
|
||||
prevBtn.disabled = currentPage === 1;
|
||||
nextBtn.disabled = currentPage === totalPages;
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (currentOffset >= itemsPerPage) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentOffset + itemsPerPage < totalItems) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshBrowser() {
|
||||
if (currentFolderId) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset, true);
|
||||
} else {
|
||||
loadMediaFolders();
|
||||
}
|
||||
}
|
||||
|
||||
// Browser search
|
||||
function onBrowserSearch() {
|
||||
const input = document.getElementById('browserSearchInput');
|
||||
const clearBtn = document.getElementById('browserSearchClear');
|
||||
const term = input.value.trim();
|
||||
|
||||
clearBtn.style.display = term ? 'flex' : 'none';
|
||||
|
||||
// Debounce: wait 200ms after typing stops
|
||||
if (browserSearchTimer) clearTimeout(browserSearchTimer);
|
||||
browserSearchTimer = setTimeout(() => {
|
||||
browserSearchTerm = term.toLowerCase();
|
||||
applyBrowserSearch();
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearBrowserSearch() {
|
||||
const input = document.getElementById('browserSearchInput');
|
||||
input.value = '';
|
||||
document.getElementById('browserSearchClear').style.display = 'none';
|
||||
browserSearchTerm = '';
|
||||
applyBrowserSearch();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function applyBrowserSearch() {
|
||||
if (!cachedItems) return;
|
||||
|
||||
if (!browserSearchTerm) {
|
||||
renderBrowserItems(cachedItems);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = cachedItems.filter(item =>
|
||||
item.name.toLowerCase().includes(browserSearchTerm) ||
|
||||
(item.title && item.title.toLowerCase().includes(browserSearchTerm))
|
||||
);
|
||||
renderBrowserItems(filtered);
|
||||
}
|
||||
|
||||
function showBrowserSearch(visible) {
|
||||
document.getElementById('browserSearchWrapper').style.display = visible ? '' : 'none';
|
||||
if (!visible) {
|
||||
document.getElementById('browserSearchInput').value = '';
|
||||
document.getElementById('browserSearchClear').style.display = 'none';
|
||||
browserSearchTerm = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setViewMode(mode) {
|
||||
if (mode === viewMode) return;
|
||||
viewMode = mode;
|
||||
localStorage.setItem('mediaBrowser.viewMode', mode);
|
||||
|
||||
// Update toggle buttons
|
||||
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
|
||||
document.getElementById(btnId).classList.add('active');
|
||||
|
||||
// Re-render current view from cache (no network request)
|
||||
if (currentFolderId && cachedItems) {
|
||||
applyBrowserSearch();
|
||||
} else {
|
||||
showRootFolders();
|
||||
}
|
||||
}
|
||||
|
||||
function onItemsPerPageChanged() {
|
||||
const select = document.getElementById('itemsPerPageSelect');
|
||||
itemsPerPage = parseInt(select.value);
|
||||
localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage);
|
||||
|
||||
// Reset to first page and reload
|
||||
if (currentFolderId) {
|
||||
currentOffset = 0;
|
||||
browsePath(currentFolderId, currentPath, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage() {
|
||||
const pageInput = document.getElementById('pageInput');
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
let page = parseInt(pageInput.value);
|
||||
|
||||
if (isNaN(page) || page < 1) page = 1;
|
||||
if (page > totalPages) page = totalPages;
|
||||
|
||||
pageInput.value = page;
|
||||
const newOffset = (page - 1) * itemsPerPage;
|
||||
if (newOffset !== currentOffset) {
|
||||
browsePath(currentFolderId, currentPath, newOffset);
|
||||
}
|
||||
}
|
||||
|
||||
function initBrowserToolbar() {
|
||||
// Restore view mode
|
||||
const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
viewMode = savedViewMode;
|
||||
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const btnId = savedViewMode === 'list' ? 'viewListBtn' : savedViewMode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
|
||||
document.getElementById(btnId).classList.add('active');
|
||||
|
||||
// Restore items per page
|
||||
const savedItemsPerPage = localStorage.getItem('mediaBrowser.itemsPerPage');
|
||||
if (savedItemsPerPage) {
|
||||
itemsPerPage = parseInt(savedItemsPerPage);
|
||||
document.getElementById('itemsPerPageSelect').value = savedItemsPerPage;
|
||||
}
|
||||
}
|
||||
|
||||
function clearBrowserGrid() {
|
||||
const grid = document.getElementById('browserGrid');
|
||||
grid.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FOLDER, t('browser.no_folder_selected'))}</div>`;
|
||||
document.getElementById('breadcrumb').innerHTML = '';
|
||||
document.getElementById('browserPagination').style.display = 'none';
|
||||
document.getElementById('playAllBtn').style.display = 'none';
|
||||
}
|
||||
|
||||
// LocalStorage for last path
|
||||
function saveLastBrowserPath(folderId, path) {
|
||||
try {
|
||||
localStorage.setItem('mediaBrowser.lastFolderId', folderId);
|
||||
localStorage.setItem('mediaBrowser.lastPath', path);
|
||||
} catch (e) {
|
||||
console.error('Failed to save last browser path:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadLastBrowserPath() {
|
||||
try {
|
||||
const lastFolderId = localStorage.getItem('mediaBrowser.lastFolderId');
|
||||
const lastPath = localStorage.getItem('mediaBrowser.lastPath');
|
||||
|
||||
if (lastFolderId && mediaFolders[lastFolderId]) {
|
||||
currentFolderId = lastFolderId;
|
||||
browsePath(lastFolderId, lastPath || '');
|
||||
} else {
|
||||
showRootFolders();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load last browser path:', e);
|
||||
showRootFolders();
|
||||
}
|
||||
}
|
||||
|
||||
// Folder Management
|
||||
function showManageFoldersDialog() {
|
||||
// TODO: Implement folder management UI
|
||||
// For now, show a simple alert
|
||||
showToast(t('browser.manage_folders_hint'), 'info');
|
||||
}
|
||||
|
||||
function closeFolderDialog() {
|
||||
closeDialog(document.getElementById('folderDialog'));
|
||||
}
|
||||
|
||||
async function saveFolder(event) {
|
||||
event.preventDefault();
|
||||
// TODO: Implement folder save functionality
|
||||
closeFolderDialog();
|
||||
}
|
||||
209
media_server/static/js/callbacks.js
Normal file
209
media_server/static/js/callbacks.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// ============================================================
|
||||
// Callbacks: CRUD management
|
||||
// ============================================================
|
||||
|
||||
let callbackFormDirty = false;
|
||||
|
||||
let _loadCallbacksPromise = null;
|
||||
async function loadCallbacksTable() {
|
||||
if (_loadCallbacksPromise) return _loadCallbacksPromise;
|
||||
_loadCallbacksPromise = _loadCallbacksTableImpl();
|
||||
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
|
||||
return _loadCallbacksPromise;
|
||||
}
|
||||
|
||||
async function _loadCallbacksTableImpl() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const tbody = document.getElementById('callbacksTableBody');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/callbacks/list', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch callbacks');
|
||||
}
|
||||
|
||||
const callbacksList = await response.json();
|
||||
|
||||
if (callbacksList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg><p>' + t('callbacks.empty') + '</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = callbacksList.map(callback => `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(callback.name)}</code></td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
|
||||
<td>${callback.timeout}s</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn execute" data-action="execute" data-callback-name="${escapeHtml(callback.name)}" title="Execute callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn" data-action="edit" data-callback-name="${escapeHtml(callback.name)}" title="Edit callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn delete" data-action="delete" data-callback-name="${escapeHtml(callback.name)}" title="Delete callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading callbacks:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function showAddCallbackDialog() {
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
const form = document.getElementById('callbackForm');
|
||||
const title = document.getElementById('callbackDialogTitle');
|
||||
|
||||
form.reset();
|
||||
document.getElementById('callbackIsEdit').value = 'false';
|
||||
document.getElementById('callbackName').disabled = false;
|
||||
title.textContent = t('callbacks.dialog.add');
|
||||
|
||||
callbackFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditCallbackDialog(callbackName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
const title = document.getElementById('callbackDialogTitle');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/callbacks/list', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch callback details');
|
||||
}
|
||||
|
||||
const callbacksList = await response.json();
|
||||
const callback = callbacksList.find(c => c.name === callbackName);
|
||||
|
||||
if (!callback) {
|
||||
showToast('Callback not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('callbackIsEdit').value = 'true';
|
||||
document.getElementById('callbackName').value = callbackName;
|
||||
document.getElementById('callbackName').disabled = true;
|
||||
document.getElementById('callbackCommand').value = callback.command;
|
||||
document.getElementById('callbackTimeout').value = callback.timeout;
|
||||
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
|
||||
|
||||
title.textContent = t('callbacks.dialog.edit');
|
||||
callbackFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading callback for edit:', error);
|
||||
showToast('Failed to load callback details', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function closeCallbackDialog() {
|
||||
if (callbackFormDirty) {
|
||||
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
callbackFormDirty = false;
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveCallback(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
|
||||
const callbackName = document.getElementById('callbackName').value;
|
||||
|
||||
const data = {
|
||||
command: document.getElementById('callbackCommand').value,
|
||||
timeout: parseInt(document.getElementById('callbackTimeout').value) || 30,
|
||||
working_dir: document.getElementById('callbackWorkingDir').value || null,
|
||||
shell: true
|
||||
};
|
||||
|
||||
const endpoint = isEdit ?
|
||||
`/api/callbacks/update/${callbackName}` :
|
||||
`/api/callbacks/create/${callbackName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
callbackFormDirty = false;
|
||||
closeCallbackDialog();
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving callback:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCallbackConfirm(callbackName) {
|
||||
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Callback deleted successfully', 'success');
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete callback', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting callback:', error);
|
||||
showToast('Error deleting callback', 'error');
|
||||
}
|
||||
}
|
||||
503
media_server/static/js/core.js
Normal file
503
media_server/static/js/core.js
Normal file
@@ -0,0 +1,503 @@
|
||||
// ============================================================
|
||||
// Core: Shared state, constants, utilities, i18n, API commands
|
||||
// ============================================================
|
||||
|
||||
// SVG path constants (avoid rebuilding innerHTML on every state update)
|
||||
const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
|
||||
const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
|
||||
const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||||
const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||||
const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||||
|
||||
// Empty state illustration SVGs
|
||||
const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
|
||||
const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
|
||||
function emptyStateHtml(svgStr, text) {
|
||||
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
|
||||
}
|
||||
|
||||
// Media source registry: substring key → { name, icon }
|
||||
const MEDIA_SOURCES = {
|
||||
'spotify': {
|
||||
name: 'Spotify',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>'
|
||||
},
|
||||
'yandex music': {
|
||||
name: 'Yandex Music',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
|
||||
},
|
||||
'яндекс музыка': {
|
||||
name: 'Яндекс Музыка',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
|
||||
},
|
||||
'chrome': {
|
||||
name: 'Google Chrome',
|
||||
icon: '<svg viewBox="0 0 24 24"><circle fill="#4587F3" cx="12" cy="12" r="11"/><path fill="#DB4437" d="M12 1C7.2 1 3.1 3.8 1.3 7.9L7.7 12l1.8-3.1c.7-1.1 1.9-1.9 3.3-1.9h9.7C21 3.5 16.9 1 12 1z"/><path fill="#0F9D58" d="M7.7 12L1.3 7.9C.5 9.2 0 10.6 0 12c0 4.5 2.8 8.4 6.8 10l3.8-6.6L7.7 12z"/><path fill="#FFCD40" d="M6.8 22c2.7 1.5 6.4 1.7 9.4.2 2.8-1.4 4.9-3.9 5.8-6.8l-6.5-3.4-1.8 3.1c-.7 1.1-1.9 1.9-3.3 1.9-.9 0-1.7-.3-2.4-.7L6.8 22z"/><circle fill="#F1F1F1" cx="12" cy="12" r="4.8"/><circle fill="#4587F3" cx="12" cy="12" r="3.8"/></svg>'
|
||||
},
|
||||
'msedge': {
|
||||
name: 'Microsoft Edge',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#0078D4" d="M21.86 17.86q.14 0 .25-.12.1-.13.1-.25 0-.06 0-.13-.12-.76-.39-1.49-.26-.72-.65-1.39-.4-.66-.92-1.25-.53-.58-1.15-1.06-.61-.48-1.3-.85-.69-.37-1.44-.6-.75-.22-1.53-.3-.8-.07-1.6 0h-.04q-.51.03-1.03.14-.5.12-1 .31-.49.2-.95.46-.46.27-.89.6-.42.32-.8.7-.37.4-.69.83-.31.44-.57.92-.25.49-.44 1 .09-.14.21-.28.12-.14.26-.27.14-.12.3-.23.16-.1.33-.18.18-.08.37-.14.18-.06.38-.08.2-.02.4-.01.21.01.41.06.28.07.53.2.25.12.47.3.21.18.39.4.18.21.32.45.14.25.23.52.1.26.14.54.04.28.02.56-.02.36-.12.72-.1.35-.27.68-.17.33-.4.62-.24.3-.52.56-.28.25-.6.46-.32.2-.67.35.44.1.9.14.44.03.89-.02.45-.05.88-.17.44-.12.85-.3.41-.2.79-.44.37-.25.71-.55.34-.3.63-.65.3-.35.54-.73.24-.39.42-.8.18-.42.3-.86.12-.43.18-.88.06-.45.06-.9 0-.48-.07-.95-.07-.47-.22-.93z"/><path fill="#50E6FF" d="M11.89.03Q10.03.17 8.3.88 6.57 1.59 5.1 2.77 3.65 3.94 2.55 5.5 1.44 7.06.79 8.88.14 10.7 0 12.65q.01.22.02.45 0 .22.03.44.04.42.12.83.08.42.2.83.12.4.28.79.16.39.36.76.2.37.43.72.24.34.51.66.27.32.57.6.3.29.63.54.33.25.68.46.35.21.72.38.38.17.77.28.39.12.79.18.41.06.82.05.41 0 .82-.07.41-.08.79-.22.39-.14.74-.34.36-.2.68-.44.33-.25.6-.54.28-.3.5-.63.23-.33.4-.7.17-.36.27-.75-1.1.9-2.44 1.36-1.33.46-2.77.46-1.26 0-2.44-.39-1.18-.39-2.17-1.08-1-1.08-1.6-2.02-.6-.94-.87-2-.27-1.07-.25-2.2.02-.55.12-1.08.1-.54.29-1.05.18-.52.44-1 .27-.49.6-.94.34-.44.74-.83.4-.38.85-.71.45-.32.94-.57.49-.25 1.02-.42.52-.16 1.07-.24.55-.07 1.1-.05.81.04 1.57.25.77.2 1.46.56.7.36 1.29.85.6.5 1.07 1.1.48.6.82 1.29.34.69.54 1.44.2.76.24 1.55.04.79-.08 1.57-.11.78-.37 1.52-.26.74-.66 1.4-.39.67-.91 1.24-.52.57-1.14 1.02-.62.44-1.32.76-.7.32-1.45.49-.75.16-1.52.18z"/></svg>'
|
||||
},
|
||||
'firefox': {
|
||||
name: 'Firefox',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FF7139" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm6.73 7.27c-.47-.77-1.22-1.6-1.7-1.87.54.97.86 2.07.93 3.15 0 0-.02.03-.02.05-.38-1.34-1.14-2.15-1.78-3.05-.03-.05-.06-.1-.1-.15-.03-.05-.05-.1-.06-.15 0-.02-.01-.04-.02-.05l-.01.02c-.02.03-.03.05-.04.08 0 0 0 .01-.01.02l.01-.02c-.64 1.07-1.72 2.2-2.1 3.56-.46.01-.9.09-1.32.23l-.06.03c-.03-.2-.04-.4-.04-.6 0-.67.15-1.3.4-1.87-1.08.4-1.93 1.12-2.53 1.72-.33-.36-.36-1.56-.34-1.8-.01 0-.03.02-.04.02-.27.2-.52.42-.75.66-.28.3-.53.62-.76.96-.12.2-.24.4-.34.6-.15.32-.27.66-.36 1-.02.07-.03.14-.05.21v.03c-.06.3-.1.6-.12.9v.1c0 .07 0 .14-.01.21C7.3 13.8 7.52 16.37 9 18.26l.04.05c-1.55-1-2.57-2.64-2.87-4.42-.04.2-.06.4-.07.6-.01.2-.02.4-.01.6.02.6.13 1.2.3 1.77.2.57.46 1.12.8 1.62.17.25.36.48.56.7.2.22.42.43.66.62 1.83 1.47 4.17 1.87 6.34 1.21.26-.08.5-.17.74-.28 1.1-.5 2.06-1.27 2.78-2.23.03-.03.05-.07.07-.1.08-.1.15-.2.22-.32.5-.77.84-1.62 1.02-2.5.02-.1.04-.2.05-.3.1-.57.14-1.15.12-1.73 0-.1-.01-.19-.02-.29.06-1.2-.15-2.42-.63-3.53-.1-.23-.2-.45-.32-.67z"/></svg>'
|
||||
},
|
||||
'opera': {
|
||||
name: 'Opera',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FF1B2D" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12c2.75 0 5.28-.93 7.3-2.49-1.24.77-2.68 1.22-4.22 1.22-2.2 0-4.17-1.1-5.55-2.83C8.1 18.1 7.2 15.22 7.2 12s.9-6.1 2.33-7.9C10.91 2.37 12.88 1.27 15.08 1.27c1.54 0 2.98.45 4.22 1.22C17.28.93 14.75 0 12 0z"/><path fill="#FF1B2D" d="M15.08 1.27c-2.2 0-4.17 1.1-5.55 2.83C8.1 5.9 7.2 8.78 7.2 12s.9 6.1 2.33 7.9c1.38 1.73 3.35 2.83 5.55 2.83 2.2 0 4.17-1.1 5.55-2.83C22.06 18.1 22.96 15.22 22.96 12s-.9-6.1-2.33-7.9c-1.38-1.73-3.35-2.83-5.55-2.83z" opacity=".75"/></svg>'
|
||||
},
|
||||
'brave': {
|
||||
name: 'Brave',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FB542B" d="M12 0L3.6 4.8v9.6L12 24l8.4-9.6V4.8L12 0zm5.7 14.1l-1.2 1.8c-.3.3-.6.6-.9.9l-2.1 1.5-1.5.9-1.5-.9-2.1-1.5c-.3-.3-.6-.6-.9-.9l-1.2-1.8c-.3-.6-.3-1.2 0-1.5l.6-1.5.6-1.2.6-1.2.3-.6c.15-.3.45-.3.6 0l.6.9c.15.3.45.3.6 0l.6-.9.6-.9c.15-.3.45-.3.6 0l.6.9.6.9c.15.3.45.3.6 0l.6-.9c.15-.3.45-.3.6 0l.3.6.6 1.2.6 1.2.6 1.5c.3.3.3.9 0 1.5z"/></svg>'
|
||||
},
|
||||
'yandex': {
|
||||
name: 'Yandex Browser',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FF0000" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M13.5 5h-2.1l-3.9 8.1V5H5.4v14h2.1l4.05-8.55V19h2.1V5z"/></svg>'
|
||||
},
|
||||
'vlc': {
|
||||
name: 'VLC',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FF8800" d="M12 1.5L7.5 16h9L12 1.5z"/><path fill="#FF5722" d="M6 18.5c-1.5 0-2.5.5-2.5 1.5s2.5 2.5 8.5 2.5 8.5-1.5 8.5-2.5-1-1.5-2.5-1.5H6z"/><path fill="#FF8800" d="M6 18.5h12l-1.5-2.5h-9L6 18.5z"/></svg>'
|
||||
},
|
||||
'aimp': {
|
||||
name: 'AIMP',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#F7A600" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M12 4l-7 14h3l1.5-3h5l1.5 3h3L12 4zm0 5l1.75 3.5h-3.5L12 9z"/></svg>'
|
||||
},
|
||||
'foobar': {
|
||||
name: 'foobar2000',
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="#1F1A17" width="24" height="24" rx="4"/><path fill="#D89B2B" d="M6 6h3v12H6V6zm4.5 0H13v12h-2.5V6zm4 0H17v12h-2.5V6z"/></svg>'
|
||||
},
|
||||
'music.ui': {
|
||||
name: 'Groove Music',
|
||||
icon: '<svg viewBox="0 0 24 24"><circle fill="#7B83EB" cx="12" cy="12" r="11"/><path fill="#FFF" d="M15 7v7a3 3 0 11-2-2.83V7h2z"/></svg>'
|
||||
},
|
||||
'itunes': {
|
||||
name: 'iTunes',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#EA4CC0" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
|
||||
},
|
||||
'apple music': {
|
||||
name: 'Apple Music',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FC3C44" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
|
||||
},
|
||||
'deezer': {
|
||||
name: 'Deezer',
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="#000" width="24" height="24" rx="4"/><g fill="#A238FF"><rect x="2" y="16" width="3" height="2" rx=".5"/><rect x="6.5" y="14" width="3" height="4" rx=".5"/><rect x="11" y="10" width="3" height="8" rx=".5"/><rect x="15.5" y="12" width="3" height="6" rx=".5"/><rect x="19" y="8" width="3" height="10" rx=".5"/></g></svg>'
|
||||
},
|
||||
'tidal': {
|
||||
name: 'TIDAL',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#000" d="M12 4.8L8 8.8l4 4-4 4-4-4 4-4-4-4 4-4 4 4zm4 0l4 4-4 4-4-4 4-4z"/></svg>'
|
||||
},
|
||||
};
|
||||
|
||||
function resolveMediaSource(raw) {
|
||||
if (!raw) return null;
|
||||
const lower = raw.toLowerCase();
|
||||
for (const [key, info] of Object.entries(MEDIA_SOURCES)) {
|
||||
if (lower.includes(key)) return info;
|
||||
}
|
||||
return { name: raw.replace(/\.exe$/i, ''), icon: null };
|
||||
}
|
||||
|
||||
// Cached DOM references (populated once after DOMContentLoaded)
|
||||
const dom = {};
|
||||
function cacheDom() {
|
||||
dom.trackTitle = document.getElementById('track-title');
|
||||
dom.artist = document.getElementById('artist');
|
||||
dom.album = document.getElementById('album');
|
||||
dom.miniTrackTitle = document.getElementById('mini-track-title');
|
||||
dom.miniArtist = document.getElementById('mini-artist');
|
||||
dom.albumArt = document.getElementById('album-art');
|
||||
dom.albumArtGlow = document.getElementById('album-art-glow');
|
||||
dom.miniAlbumArt = document.getElementById('mini-album-art');
|
||||
dom.volumeSlider = document.getElementById('volume-slider');
|
||||
dom.volumeDisplay = document.getElementById('volume-display');
|
||||
dom.miniVolumeSlider = document.getElementById('mini-volume-slider');
|
||||
dom.miniVolumeDisplay = document.getElementById('mini-volume-display');
|
||||
dom.progressFill = document.getElementById('progress-fill');
|
||||
dom.currentTime = document.getElementById('current-time');
|
||||
dom.totalTime = document.getElementById('total-time');
|
||||
dom.progressBar = document.getElementById('progress-bar');
|
||||
dom.miniProgressFill = document.getElementById('mini-progress-fill');
|
||||
dom.miniCurrentTime = document.getElementById('mini-current-time');
|
||||
dom.miniTotalTime = document.getElementById('mini-total-time');
|
||||
dom.playbackState = document.getElementById('playback-state');
|
||||
dom.stateIcon = document.getElementById('state-icon');
|
||||
dom.playPauseIcon = document.getElementById('play-pause-icon');
|
||||
dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
|
||||
dom.muteIcon = document.getElementById('mute-icon');
|
||||
dom.miniMuteIcon = document.getElementById('mini-mute-icon');
|
||||
dom.statusDot = document.getElementById('status-dot');
|
||||
dom.source = document.getElementById('source');
|
||||
dom.sourceIcon = document.getElementById('sourceIcon');
|
||||
dom.btnPlayPause = document.getElementById('btn-play-pause');
|
||||
dom.btnNext = document.getElementById('btn-next');
|
||||
dom.btnPrevious = document.getElementById('btn-previous');
|
||||
dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause');
|
||||
dom.miniPlayer = document.getElementById('mini-player');
|
||||
}
|
||||
|
||||
// Timing constants
|
||||
const VOLUME_THROTTLE_MS = 16;
|
||||
const POSITION_INTERPOLATION_MS = 100;
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
const TOAST_DURATION_MS = 3000;
|
||||
const WS_BACKOFF_BASE_MS = 3000;
|
||||
const WS_BACKOFF_MAX_MS = 30000;
|
||||
const WS_MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const WS_PING_INTERVAL_MS = 30000;
|
||||
const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
|
||||
// Shared state (accessed across multiple modules)
|
||||
let ws = null;
|
||||
let currentState = 'idle';
|
||||
let currentDuration = 0;
|
||||
let currentPosition = 0;
|
||||
let isUserAdjustingVolume = false;
|
||||
let volumeUpdateTimer = null;
|
||||
let scripts = [];
|
||||
let lastStatus = null;
|
||||
let currentPlayState = 'idle';
|
||||
|
||||
// ============================================================
|
||||
// Internationalization (i18n)
|
||||
// ============================================================
|
||||
|
||||
let currentLocale = 'en';
|
||||
let translations = {};
|
||||
const supportedLocales = {
|
||||
'en': 'English',
|
||||
'ru': 'Русский'
|
||||
};
|
||||
|
||||
// Minimal inline fallback for critical UI elements
|
||||
const fallbackTranslations = {
|
||||
'app.title': 'Media Server',
|
||||
'auth.connect': 'Connect',
|
||||
'auth.placeholder': 'Enter API Token',
|
||||
'player.status.connected': 'Connected',
|
||||
'player.status.disconnected': 'Disconnected'
|
||||
};
|
||||
|
||||
function t(key, params = {}) {
|
||||
let text = translations[key] || fallbackTranslations[key] || key;
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
async function loadTranslations(locale) {
|
||||
try {
|
||||
const response = await fetch(`/static/locales/${locale}.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${locale}.json`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error loading translations for ${locale}:`, error);
|
||||
if (locale !== 'en') {
|
||||
return await loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function detectBrowserLocale() {
|
||||
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
|
||||
const langCode = browserLang.split('-')[0];
|
||||
return supportedLocales[langCode] ? langCode : 'en';
|
||||
}
|
||||
|
||||
async function initLocale() {
|
||||
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
|
||||
await setLocale(savedLocale);
|
||||
}
|
||||
|
||||
async function setLocale(locale) {
|
||||
if (!supportedLocales[locale]) {
|
||||
locale = 'en';
|
||||
}
|
||||
translations = await loadTranslations(locale);
|
||||
currentLocale = locale;
|
||||
document.documentElement.setAttribute('data-locale', locale);
|
||||
document.documentElement.setAttribute('lang', locale);
|
||||
localStorage.setItem('locale', locale);
|
||||
updateAllText();
|
||||
updateLocaleSelect();
|
||||
document.body.classList.remove('loading-translations');
|
||||
document.body.classList.add('translations-loaded');
|
||||
}
|
||||
|
||||
function changeLocale() {
|
||||
const select = document.getElementById('locale-select');
|
||||
const newLocale = select.value;
|
||||
if (newLocale && newLocale !== currentLocale) {
|
||||
localStorage.setItem('locale', newLocale);
|
||||
setLocale(newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocaleSelect() {
|
||||
const select = document.getElementById('locale-select');
|
||||
if (select) {
|
||||
select.value = currentLocale;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAllText() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
|
||||
// Re-apply dynamic content with new translations
|
||||
updatePlaybackState(currentState);
|
||||
const connected = ws && ws.readyState === WebSocket.OPEN;
|
||||
updateConnectionStatus(connected);
|
||||
|
||||
if (lastStatus) {
|
||||
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle;
|
||||
const initSrc = resolveMediaSource(lastStatus.source);
|
||||
document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source');
|
||||
document.getElementById('sourceIcon').innerHTML = initSrc?.icon || '';
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (token) {
|
||||
loadScriptsTable();
|
||||
loadCallbacksTable();
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
}
|
||||
renderAccentSwatches();
|
||||
}
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const label = document.getElementById('version-label');
|
||||
if (data.version) {
|
||||
label.textContent = `v${data.version}`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching version:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Shared Utilities
|
||||
// ============================================================
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!seconds || seconds < 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||||
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
|
||||
}, TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
function closeDialog(dialog) {
|
||||
dialog.classList.add('dialog-closing');
|
||||
dialog.addEventListener('animationend', () => {
|
||||
dialog.classList.remove('dialog-closing');
|
||||
dialog.close();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('confirmDialog');
|
||||
const msg = document.getElementById('confirmDialogMessage');
|
||||
const btnCancel = document.getElementById('confirmDialogCancel');
|
||||
const btnConfirm = document.getElementById('confirmDialogConfirm');
|
||||
|
||||
msg.textContent = message;
|
||||
|
||||
function cleanup() {
|
||||
btnCancel.removeEventListener('click', onCancel);
|
||||
btnConfirm.removeEventListener('click', onConfirm);
|
||||
dialog.removeEventListener('close', onClose);
|
||||
closeDialog(dialog);
|
||||
}
|
||||
|
||||
function onCancel() { cleanup(); resolve(false); }
|
||||
function onConfirm() { cleanup(); resolve(true); }
|
||||
function onClose() { cleanup(); resolve(false); }
|
||||
|
||||
btnCancel.addEventListener('click', onCancel);
|
||||
btnConfirm.addEventListener('click', onConfirm);
|
||||
dialog.addEventListener('close', onClose);
|
||||
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Commands
|
||||
// ============================================================
|
||||
|
||||
async function sendCommand(endpoint, body = null) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/media/${endpoint}`, options);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
console.error(`Command ${endpoint} failed:`, response.status);
|
||||
showToast(data.detail || `Command failed: ${endpoint}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sending command ${endpoint}:`, error);
|
||||
showToast(`Connection error: ${endpoint}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (currentState === 'playing') {
|
||||
sendCommand('pause');
|
||||
} else {
|
||||
sendCommand('play');
|
||||
}
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
sendCommand('next');
|
||||
}
|
||||
|
||||
function previousTrack() {
|
||||
sendCommand('previous');
|
||||
}
|
||||
|
||||
let lastSentVolume = -1;
|
||||
function setVolume(volume) {
|
||||
if (volume === lastSentVolume) return;
|
||||
lastSentVolume = volume;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'volume', volume: volume }));
|
||||
} else {
|
||||
sendCommand('volume', { volume: volume });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
sendCommand('mute');
|
||||
}
|
||||
|
||||
function seek(position) {
|
||||
sendCommand('seek', { position: position });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MDI Icon System
|
||||
// ============================================================
|
||||
|
||||
const mdiIconCache = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('mdiIconCache') || '{}');
|
||||
} catch { return {}; }
|
||||
})();
|
||||
|
||||
function _persistMdiCache() {
|
||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
||||
}
|
||||
|
||||
async function fetchMdiIcon(iconName) {
|
||||
const name = iconName.replace(/^mdi:/, '');
|
||||
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
|
||||
if (response.ok) {
|
||||
const svg = await response.text();
|
||||
mdiIconCache[name] = svg;
|
||||
_persistMdiCache();
|
||||
return svg;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch MDI icon:', name, e);
|
||||
}
|
||||
|
||||
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
}
|
||||
|
||||
async function resolveMdiIcons(container) {
|
||||
const els = container.querySelectorAll('[data-mdi-icon]');
|
||||
await Promise.all(Array.from(els).map(async (el) => {
|
||||
const icon = el.dataset.mdiIcon;
|
||||
if (icon) {
|
||||
el.innerHTML = await fetchMdiIcon(icon);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function setupIconPreview(inputId, previewId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
if (!input || !preview) return;
|
||||
|
||||
let debounceTimer = null;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(debounceTimer);
|
||||
const value = input.value.trim();
|
||||
|
||||
if (!value) {
|
||||
preview.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const svg = await fetchMdiIcon(value);
|
||||
if (input.value.trim() === value) {
|
||||
preview.innerHTML = svg;
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
414
media_server/static/js/links.js
Normal file
414
media_server/static/js/links.js
Normal file
@@ -0,0 +1,414 @@
|
||||
// ============================================================
|
||||
// Display Brightness & Power Control
|
||||
// ============================================================
|
||||
|
||||
let displayBrightnessTimers = {};
|
||||
const DISPLAY_THROTTLE_MS = 50;
|
||||
|
||||
async function loadDisplayMonitors() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
|
||||
const container = document.getElementById('displayMonitors');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/display/monitors?refresh=true', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
container.innerHTML = `<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<p data-i18n="display.error">Failed to load monitors</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const monitors = await response.json();
|
||||
|
||||
if (monitors.length === 0) {
|
||||
container.innerHTML = `<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<p data-i18n="display.no_monitors">No monitors detected</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
monitors.forEach(monitor => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'display-monitor-card';
|
||||
card.id = `monitor-card-${monitor.id}`;
|
||||
|
||||
const brightnessValue = monitor.brightness !== null ? monitor.brightness : 0;
|
||||
const brightnessDisabled = monitor.brightness === null ? 'disabled' : '';
|
||||
|
||||
let powerBtn = '';
|
||||
if (monitor.power_supported) {
|
||||
powerBtn = `
|
||||
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
|
||||
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
|
||||
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
||||
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary ? `<span class="display-primary-badge">${t('display.primary')}</span>` : '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="display-monitor-header">
|
||||
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
|
||||
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
|
||||
</svg>
|
||||
<div class="display-monitor-info">
|
||||
<span class="display-monitor-name">${monitor.name}${primaryBadge}</span>
|
||||
${detailsHtml}
|
||||
</div>
|
||||
${powerBtn}
|
||||
</div>
|
||||
<div class="display-brightness-control">
|
||||
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
|
||||
</svg>
|
||||
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
||||
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
|
||||
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
|
||||
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||
</div>`;
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load display monitors:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function onDisplayBrightnessInput(monitorId, value) {
|
||||
const label = document.getElementById(`brightness-val-${monitorId}`);
|
||||
if (label) label.textContent = `${value}%`;
|
||||
|
||||
if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]);
|
||||
displayBrightnessTimers[monitorId] = setTimeout(() => {
|
||||
sendDisplayBrightness(monitorId, parseInt(value));
|
||||
displayBrightnessTimers[monitorId] = null;
|
||||
}, DISPLAY_THROTTLE_MS);
|
||||
}
|
||||
|
||||
function onDisplayBrightnessChange(monitorId, value) {
|
||||
if (displayBrightnessTimers[monitorId]) {
|
||||
clearTimeout(displayBrightnessTimers[monitorId]);
|
||||
displayBrightnessTimers[monitorId] = null;
|
||||
}
|
||||
sendDisplayBrightness(monitorId, parseInt(value));
|
||||
}
|
||||
|
||||
async function sendDisplayBrightness(monitorId, brightness) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
try {
|
||||
await fetch(`/api/display/brightness/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ brightness })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to set brightness:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDisplayPower(monitorId, monitorName) {
|
||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||
const isOn = btn && btn.classList.contains('on');
|
||||
const newState = !isOn;
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
try {
|
||||
const response = await fetch(`/api/display/power/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ on: newState })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
if (btn) {
|
||||
btn.classList.toggle('on', newState);
|
||||
btn.classList.toggle('off', !newState);
|
||||
btn.title = newState ? t('display.power_off') : t('display.power_on');
|
||||
}
|
||||
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
|
||||
} else {
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set display power:', e);
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Header Quick Links
|
||||
// ============================================================
|
||||
|
||||
async function loadHeaderLinks() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
|
||||
const container = document.getElementById('headerLinks');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/links/list', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const links = await response.json();
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const link of links) {
|
||||
const a = document.createElement('a');
|
||||
a.href = link.url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.className = 'header-link';
|
||||
a.title = link.label || link.url;
|
||||
|
||||
const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link');
|
||||
a.innerHTML = iconSvg;
|
||||
|
||||
container.appendChild(a);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load header links:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Links Management
|
||||
// ============================================================
|
||||
|
||||
let _loadLinksPromise = null;
|
||||
let linkFormDirty = false;
|
||||
|
||||
async function loadLinksTable() {
|
||||
if (_loadLinksPromise) return _loadLinksPromise;
|
||||
_loadLinksPromise = _loadLinksTableImpl();
|
||||
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
|
||||
return _loadLinksPromise;
|
||||
}
|
||||
|
||||
async function _loadLinksTableImpl() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const tbody = document.getElementById('linksTableBody');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/links/list', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch links');
|
||||
}
|
||||
|
||||
const linksList = await response.json();
|
||||
|
||||
if (linksList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg><p>' + t('links.empty') + '</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = linksList.map(link => `
|
||||
<tr>
|
||||
<td><span class="name-with-icon"><span class="table-icon" data-mdi-icon="${escapeHtml(link.icon || 'mdi:link')}"></span><code>${escapeHtml(link.name)}</code></span></td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
title="${escapeHtml(link.url)}">${escapeHtml(link.url)}</td>
|
||||
<td>${escapeHtml(link.label || '')}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" data-action="edit" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.edit')}">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn delete" data-action="delete" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.delete')}">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading links:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function showAddLinkDialog() {
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
const form = document.getElementById('linkForm');
|
||||
const title = document.getElementById('linkDialogTitle');
|
||||
|
||||
form.reset();
|
||||
document.getElementById('linkOriginalName').value = '';
|
||||
document.getElementById('linkIsEdit').value = 'false';
|
||||
document.getElementById('linkName').disabled = false;
|
||||
document.getElementById('linkIconPreview').innerHTML = '';
|
||||
title.textContent = t('links.dialog.add');
|
||||
|
||||
linkFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditLinkDialog(linkName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
const title = document.getElementById('linkDialogTitle');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/links/list', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch link details');
|
||||
}
|
||||
|
||||
const linksList = await response.json();
|
||||
const link = linksList.find(l => l.name === linkName);
|
||||
|
||||
if (!link) {
|
||||
showToast(t('links.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('linkOriginalName').value = linkName;
|
||||
document.getElementById('linkIsEdit').value = 'true';
|
||||
document.getElementById('linkName').value = linkName;
|
||||
document.getElementById('linkName').disabled = true;
|
||||
document.getElementById('linkUrl').value = link.url;
|
||||
document.getElementById('linkIcon').value = link.icon || '';
|
||||
document.getElementById('linkLabel').value = link.label || '';
|
||||
document.getElementById('linkDescription').value = link.description || '';
|
||||
|
||||
// Update icon preview
|
||||
const preview = document.getElementById('linkIconPreview');
|
||||
if (link.icon) {
|
||||
fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; });
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
|
||||
title.textContent = t('links.dialog.edit');
|
||||
|
||||
linkFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading link for edit:', error);
|
||||
showToast(t('links.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function closeLinkDialog() {
|
||||
if (linkFormDirty) {
|
||||
if (!await showConfirm(t('links.confirm.unsaved'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
linkFormDirty = false;
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveLink(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const isEdit = document.getElementById('linkIsEdit').value === 'true';
|
||||
const linkName = isEdit ?
|
||||
document.getElementById('linkOriginalName').value :
|
||||
document.getElementById('linkName').value;
|
||||
|
||||
const data = {
|
||||
url: document.getElementById('linkUrl').value,
|
||||
icon: document.getElementById('linkIcon').value || 'mdi:link',
|
||||
label: document.getElementById('linkLabel').value || '',
|
||||
description: document.getElementById('linkDescription').value || ''
|
||||
};
|
||||
|
||||
const endpoint = isEdit ?
|
||||
`/api/links/update/${linkName}` :
|
||||
`/api/links/create/${linkName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success');
|
||||
linkFormDirty = false;
|
||||
closeLinkDialog();
|
||||
} else {
|
||||
showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving link:', error);
|
||||
showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLinkConfirm(linkName) {
|
||||
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/links/delete/${linkName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(t('links.msg.deleted'), 'success');
|
||||
} else {
|
||||
showToast(result.detail || t('links.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting link:', error);
|
||||
showToast(t('links.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
294
media_server/static/js/main.js
Normal file
294
media_server/static/js/main.js
Normal file
@@ -0,0 +1,294 @@
|
||||
// ============================================================
|
||||
// Main: Initialization orchestrator (loaded last)
|
||||
// ============================================================
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cache DOM references
|
||||
cacheDom();
|
||||
|
||||
// Initialize theme and accent color
|
||||
initTheme();
|
||||
initAccentColor();
|
||||
|
||||
// Register service worker for PWA installability
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
// Initialize vinyl mode
|
||||
applyVinylMode();
|
||||
|
||||
// Initialize audio visualizer
|
||||
checkVisualizerAvailability().then(() => {
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
applyVisualizerMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize dynamic background
|
||||
applyDynamicBackground();
|
||||
|
||||
// Initialize locale (async - loads JSON file)
|
||||
await initLocale();
|
||||
|
||||
// Load version from health endpoint
|
||||
fetchVersion();
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (token) {
|
||||
connectWebSocket(token);
|
||||
loadScripts();
|
||||
loadScriptsTable();
|
||||
loadCallbacksTable();
|
||||
loadLinksTable();
|
||||
loadAudioDevices();
|
||||
} else {
|
||||
showAuthForm();
|
||||
}
|
||||
|
||||
// Shared volume slider setup (avoids duplicate handler code)
|
||||
function setupVolumeSlider(sliderId) {
|
||||
const slider = document.getElementById(sliderId);
|
||||
slider.addEventListener('input', (e) => {
|
||||
isUserAdjustingVolume = true;
|
||||
const volume = parseInt(e.target.value);
|
||||
// Sync both sliders and displays
|
||||
dom.volumeDisplay.textContent = `${volume}%`;
|
||||
dom.miniVolumeDisplay.textContent = `${volume}%`;
|
||||
dom.volumeSlider.value = volume;
|
||||
dom.miniVolumeSlider.value = volume;
|
||||
|
||||
if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = setTimeout(() => {
|
||||
setVolume(volume);
|
||||
volumeUpdateTimer = null;
|
||||
}, VOLUME_THROTTLE_MS);
|
||||
});
|
||||
|
||||
slider.addEventListener('change', (e) => {
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = null;
|
||||
}
|
||||
const volume = parseInt(e.target.value);
|
||||
setVolume(volume);
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
setupVolumeSlider('volume-slider');
|
||||
setupVolumeSlider('mini-volume-slider');
|
||||
|
||||
// Restore saved tab (migrate old tab names)
|
||||
let savedTab = localStorage.getItem('activeTab') || 'player';
|
||||
if (['scripts', 'callbacks', 'links'].includes(savedTab)) savedTab = 'settings';
|
||||
switchTab(savedTab);
|
||||
// Snap indicator to initial position without animation
|
||||
const initialActiveBtn = document.querySelector('.tab-btn.active');
|
||||
if (initialActiveBtn) updateTabIndicator(initialActiveBtn, false);
|
||||
|
||||
// Re-position tab indicator on window resize
|
||||
window.addEventListener('resize', () => {
|
||||
const activeBtn = document.querySelector('.tab-btn.active');
|
||||
if (activeBtn) updateTabIndicator(activeBtn, false);
|
||||
});
|
||||
|
||||
// Mini Player: Intersection Observer to show/hide when main player scrolls out of view
|
||||
const playerContainer = document.querySelector('.player-container');
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (activeTab !== 'player') return;
|
||||
setMiniPlayerVisible(!entry.isIntersecting);
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
observer.observe(playerContainer);
|
||||
|
||||
// Drag-to-seek for progress bars
|
||||
setupProgressDrag(
|
||||
document.getElementById('mini-progress-bar'),
|
||||
document.getElementById('mini-progress-fill')
|
||||
);
|
||||
setupProgressDrag(
|
||||
document.getElementById('progress-bar'),
|
||||
document.getElementById('progress-fill')
|
||||
);
|
||||
|
||||
// Enter key in token input
|
||||
document.getElementById('token-input').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
authenticate();
|
||||
}
|
||||
});
|
||||
|
||||
// Script form dirty state tracking
|
||||
const scriptForm = document.getElementById('scriptForm');
|
||||
scriptForm.addEventListener('input', () => {
|
||||
scriptFormDirty = true;
|
||||
});
|
||||
scriptForm.addEventListener('change', () => {
|
||||
scriptFormDirty = true;
|
||||
});
|
||||
|
||||
// Callback form dirty state tracking
|
||||
const callbackForm = document.getElementById('callbackForm');
|
||||
callbackForm.addEventListener('input', () => {
|
||||
callbackFormDirty = true;
|
||||
});
|
||||
callbackForm.addEventListener('change', () => {
|
||||
callbackFormDirty = true;
|
||||
});
|
||||
|
||||
// Script dialog backdrop click to close
|
||||
const scriptDialog = document.getElementById('scriptDialog');
|
||||
scriptDialog.addEventListener('click', (e) => {
|
||||
// Check if click is on the backdrop (not the dialog content)
|
||||
if (e.target === scriptDialog) {
|
||||
closeScriptDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Callback dialog backdrop click to close
|
||||
const callbackDialog = document.getElementById('callbackDialog');
|
||||
callbackDialog.addEventListener('click', (e) => {
|
||||
// Check if click is on the backdrop (not the dialog content)
|
||||
if (e.target === callbackDialog) {
|
||||
closeCallbackDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Delegated click handlers for script table actions (XSS-safe)
|
||||
document.getElementById('scriptsTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const name = btn.dataset.scriptName;
|
||||
if (action === 'execute') executeScriptDebug(name);
|
||||
else if (action === 'edit') showEditScriptDialog(name);
|
||||
else if (action === 'delete') deleteScriptConfirm(name);
|
||||
});
|
||||
|
||||
// Delegated click handlers for callback table actions (XSS-safe)
|
||||
document.getElementById('callbacksTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const name = btn.dataset.callbackName;
|
||||
if (action === 'execute') executeCallbackDebug(name);
|
||||
else if (action === 'edit') showEditCallbackDialog(name);
|
||||
else if (action === 'delete') deleteCallbackConfirm(name);
|
||||
});
|
||||
|
||||
// Link dialog backdrop click to close
|
||||
const linkDialog = document.getElementById('linkDialog');
|
||||
linkDialog.addEventListener('click', (e) => {
|
||||
if (e.target === linkDialog) {
|
||||
closeLinkDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Delegated click handlers for link table actions (XSS-safe)
|
||||
document.getElementById('linksTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const name = btn.dataset.linkName;
|
||||
if (action === 'edit') showEditLinkDialog(name);
|
||||
else if (action === 'delete') deleteLinkConfirm(name);
|
||||
});
|
||||
|
||||
// Track link form dirty state
|
||||
const linkForm = document.getElementById('linkForm');
|
||||
linkForm.addEventListener('input', () => {
|
||||
linkFormDirty = true;
|
||||
});
|
||||
linkForm.addEventListener('change', () => {
|
||||
linkFormDirty = true;
|
||||
});
|
||||
|
||||
// Initialize browser toolbar and load folders
|
||||
initBrowserToolbar();
|
||||
if (token) {
|
||||
loadMediaFolders();
|
||||
}
|
||||
|
||||
// Icon preview for script and link dialogs
|
||||
setupIconPreview('scriptIcon', 'scriptIconPreview');
|
||||
setupIconPreview('linkIcon', 'linkIconPreview');
|
||||
|
||||
// Settings sections: restore collapse state and persist on toggle
|
||||
document.querySelectorAll('.settings-section').forEach(details => {
|
||||
const key = `settings_section_${details.querySelector('summary')?.getAttribute('data-i18n') || ''}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved === 'closed') details.removeAttribute('open');
|
||||
else if (saved === 'open') details.setAttribute('open', '');
|
||||
details.addEventListener('toggle', () => {
|
||||
localStorage.setItem(key, details.open ? 'open' : 'closed');
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup blob URLs on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
|
||||
thumbnailCache.clear();
|
||||
});
|
||||
|
||||
// Tab bar keyboard navigation (WAI-ARIA Tabs pattern)
|
||||
document.getElementById('tabBar').addEventListener('keydown', (e) => {
|
||||
const tabs = Array.from(document.querySelectorAll('.tab-btn'));
|
||||
const currentIdx = tabs.indexOf(document.activeElement);
|
||||
if (currentIdx === -1) return;
|
||||
|
||||
let newIdx;
|
||||
if (e.key === 'ArrowRight') {
|
||||
newIdx = (currentIdx + 1) % tabs.length;
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
newIdx = (currentIdx - 1 + tabs.length) % tabs.length;
|
||||
} else if (e.key === 'Home') {
|
||||
newIdx = 0;
|
||||
} else if (e.key === 'End') {
|
||||
newIdx = tabs.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
tabs[newIdx].focus();
|
||||
switchTab(tabs[newIdx].dataset.tab);
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Skip when typing in inputs, textareas, selects, or when a dialog is open
|
||||
const tag = e.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (document.querySelector('dialog[open]')) return;
|
||||
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
togglePlayPause();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (currentDuration > 0) seek(Math.max(0, currentPosition - 5));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
if (currentDuration > 0) seek(Math.min(currentDuration, currentPosition + 5));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setVolume(Math.min(100, parseInt(dom.volumeSlider.value) + 5));
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setVolume(Math.max(0, parseInt(dom.volumeSlider.value) - 5));
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
toggleMute();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
742
media_server/static/js/player.js
Normal file
742
media_server/static/js/player.js
Normal file
@@ -0,0 +1,742 @@
|
||||
// ============================================================
|
||||
// Player: Tabs, theme, accent, vinyl, visualizer, UI updates
|
||||
// ============================================================
|
||||
|
||||
// Tab management
|
||||
let activeTab = 'player';
|
||||
|
||||
function setMiniPlayerVisible(visible) {
|
||||
const miniPlayer = document.getElementById('mini-player');
|
||||
if (visible) {
|
||||
miniPlayer.classList.remove('hidden');
|
||||
document.body.classList.add('mini-player-visible');
|
||||
} else {
|
||||
miniPlayer.classList.add('hidden');
|
||||
document.body.classList.remove('mini-player-visible');
|
||||
}
|
||||
}
|
||||
|
||||
function updateTabIndicator(btn, animate = true) {
|
||||
const indicator = document.getElementById('tabIndicator');
|
||||
if (!indicator || !btn) return;
|
||||
const tabBar = document.getElementById('tabBar');
|
||||
const barRect = tabBar.getBoundingClientRect();
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
const offset = btnRect.left - barRect.left - parseFloat(getComputedStyle(tabBar).paddingLeft || 0);
|
||||
if (!animate) indicator.style.transition = 'none';
|
||||
indicator.style.width = btnRect.width + 'px';
|
||||
indicator.style.transform = `translateX(${offset}px)`;
|
||||
if (!animate) {
|
||||
indicator.offsetHeight;
|
||||
indicator.style.transition = '';
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
activeTab = tabName;
|
||||
|
||||
document.querySelectorAll('[data-tab-content]').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
el.style.display = '';
|
||||
});
|
||||
|
||||
const target = document.querySelector(`[data-tab-content="${tabName}"]`);
|
||||
if (target) {
|
||||
target.classList.add('active');
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-selected', 'false');
|
||||
btn.setAttribute('tabindex', '-1');
|
||||
});
|
||||
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('active');
|
||||
activeBtn.setAttribute('aria-selected', 'true');
|
||||
activeBtn.setAttribute('tabindex', '0');
|
||||
updateTabIndicator(activeBtn);
|
||||
}
|
||||
|
||||
if (tabName === 'display') {
|
||||
loadDisplayMonitors();
|
||||
}
|
||||
|
||||
localStorage.setItem('activeTab', tabName);
|
||||
|
||||
if (tabName !== 'player') {
|
||||
setMiniPlayerVisible(true);
|
||||
} else {
|
||||
const playerContainer = document.querySelector('.player-container');
|
||||
const rect = playerContainer.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
setMiniPlayerVisible(!inView);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
const sunIcon = document.getElementById('theme-icon-sun');
|
||||
const moonIcon = document.getElementById('theme-icon-moon');
|
||||
|
||||
if (theme === 'light') {
|
||||
sunIcon.style.display = 'none';
|
||||
moonIcon.style.display = 'block';
|
||||
} else {
|
||||
sunIcon.style.display = 'block';
|
||||
moonIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212');
|
||||
}
|
||||
|
||||
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
}
|
||||
|
||||
// Accent color management
|
||||
const accentPresets = [
|
||||
{ name: 'Green', color: '#1db954', hover: '#1ed760' },
|
||||
{ name: 'Blue', color: '#3b82f6', hover: '#60a5fa' },
|
||||
{ name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' },
|
||||
{ name: 'Pink', color: '#ec4899', hover: '#f472b6' },
|
||||
{ name: 'Orange', color: '#f97316', hover: '#fb923c' },
|
||||
{ name: 'Red', color: '#ef4444', hover: '#f87171' },
|
||||
{ name: 'Teal', color: '#14b8a6', hover: '#2dd4bf' },
|
||||
{ name: 'Cyan', color: '#06b6d4', hover: '#22d3ee' },
|
||||
{ name: 'Yellow', color: '#eab308', hover: '#facc15' },
|
||||
];
|
||||
|
||||
function lightenColor(hex, percent) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
|
||||
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
|
||||
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function initAccentColor() {
|
||||
const saved = localStorage.getItem('accentColor');
|
||||
if (saved) {
|
||||
const preset = accentPresets.find(p => p.color === saved);
|
||||
if (preset) {
|
||||
applyAccentColor(preset.color, preset.hover);
|
||||
} else {
|
||||
applyAccentColor(saved, lightenColor(saved, 15));
|
||||
}
|
||||
}
|
||||
renderAccentSwatches();
|
||||
}
|
||||
|
||||
function applyAccentColor(color, hover) {
|
||||
document.documentElement.style.setProperty('--accent', color);
|
||||
document.documentElement.style.setProperty('--accent-hover', hover);
|
||||
localStorage.setItem('accentColor', color);
|
||||
const dot = document.getElementById('accentDot');
|
||||
if (dot) dot.style.background = color;
|
||||
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
|
||||
}
|
||||
|
||||
function renderAccentSwatches() {
|
||||
const dropdown = document.getElementById('accentDropdown');
|
||||
if (!dropdown) return;
|
||||
const current = localStorage.getItem('accentColor') || '#1db954';
|
||||
const isCustom = !accentPresets.some(p => p.color === current);
|
||||
|
||||
const swatches = accentPresets.map(p =>
|
||||
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
|
||||
style="background: ${p.color}"
|
||||
onclick="selectAccentColor('${p.color}', '${p.hover}')"
|
||||
title="${p.name}"></div>`
|
||||
).join('');
|
||||
|
||||
const customRow = `
|
||||
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()">
|
||||
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
|
||||
<span class="accent-custom-label">${t('accent.custom')}</span>
|
||||
<input type="color" id="accentCustomInput" value="${current}"
|
||||
onclick="event.stopPropagation()"
|
||||
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
|
||||
</div>`;
|
||||
|
||||
dropdown.innerHTML = swatches + customRow;
|
||||
}
|
||||
|
||||
function selectAccentColor(color, hover) {
|
||||
applyAccentColor(color, hover);
|
||||
renderAccentSwatches();
|
||||
document.getElementById('accentDropdown').classList.remove('open');
|
||||
}
|
||||
|
||||
function toggleAccentPicker() {
|
||||
document.getElementById('accentDropdown').classList.toggle('open');
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.accent-picker')) {
|
||||
document.getElementById('accentDropdown')?.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Vinyl mode
|
||||
let vinylMode = localStorage.getItem('vinylMode') === 'true';
|
||||
|
||||
function getVinylAngle() {
|
||||
const art = document.getElementById('album-art');
|
||||
if (!art) return 0;
|
||||
const st = getComputedStyle(art);
|
||||
const tr = st.transform;
|
||||
if (!tr || tr === 'none') return 0;
|
||||
const m = tr.match(/matrix\((.+)\)/);
|
||||
if (!m) return 0;
|
||||
const vals = m[1].split(',').map(Number);
|
||||
const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI));
|
||||
return ((angle % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function saveVinylAngle() {
|
||||
if (!vinylMode) return;
|
||||
localStorage.setItem('vinylAngle', getVinylAngle());
|
||||
}
|
||||
|
||||
function restoreVinylAngle() {
|
||||
const saved = localStorage.getItem('vinylAngle');
|
||||
if (saved) {
|
||||
const art = document.getElementById('album-art');
|
||||
if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(saveVinylAngle, 2000);
|
||||
window.addEventListener('beforeunload', saveVinylAngle);
|
||||
|
||||
function toggleVinylMode() {
|
||||
if (vinylMode) saveVinylAngle();
|
||||
vinylMode = !vinylMode;
|
||||
localStorage.setItem('vinylMode', vinylMode);
|
||||
applyVinylMode();
|
||||
}
|
||||
|
||||
function applyVinylMode() {
|
||||
const container = document.querySelector('.album-art-container');
|
||||
const btn = document.getElementById('vinylToggle');
|
||||
if (!container) return;
|
||||
if (vinylMode) {
|
||||
container.classList.add('vinyl');
|
||||
if (btn) btn.classList.add('active');
|
||||
restoreVinylAngle();
|
||||
updateVinylSpin();
|
||||
} else {
|
||||
saveVinylAngle();
|
||||
container.classList.remove('vinyl', 'spinning', 'paused');
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function updateVinylSpin() {
|
||||
const container = document.querySelector('.album-art-container');
|
||||
if (!container || !vinylMode) return;
|
||||
container.classList.remove('spinning', 'paused');
|
||||
if (currentPlayState === 'playing') {
|
||||
container.classList.add('spinning');
|
||||
} else if (currentPlayState === 'paused') {
|
||||
container.classList.add('paused');
|
||||
}
|
||||
}
|
||||
|
||||
// Audio Visualizer
|
||||
let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
||||
let visualizerAvailable = false;
|
||||
let visualizerCtx = null;
|
||||
let visualizerAnimFrame = null;
|
||||
let frequencyData = null;
|
||||
let smoothedFrequencies = null;
|
||||
const VISUALIZER_SMOOTHING = 0.15;
|
||||
|
||||
async function checkVisualizerAvailability() {
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const resp = await fetch('/api/media/visualizer/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
visualizerAvailable = data.available;
|
||||
}
|
||||
} catch (e) {
|
||||
visualizerAvailable = false;
|
||||
}
|
||||
const btn = document.getElementById('visualizerToggle');
|
||||
if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
|
||||
}
|
||||
|
||||
function toggleVisualizer() {
|
||||
visualizerEnabled = !visualizerEnabled;
|
||||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||||
applyVisualizerMode();
|
||||
}
|
||||
|
||||
function applyVisualizerMode() {
|
||||
const container = document.querySelector('.album-art-container');
|
||||
const btn = document.getElementById('visualizerToggle');
|
||||
if (!container) return;
|
||||
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
container.classList.add('visualizer-active');
|
||||
if (btn) btn.classList.add('active');
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
initVisualizerCanvas();
|
||||
startVisualizerRender();
|
||||
} else {
|
||||
container.classList.remove('visualizer-active');
|
||||
if (btn) btn.classList.remove('active');
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'disable_visualizer' }));
|
||||
}
|
||||
stopVisualizerRender();
|
||||
}
|
||||
|
||||
// Sync the audio device status badge with the new capture state
|
||||
updateAudioDeviceStatus({
|
||||
running: visualizerEnabled && visualizerAvailable,
|
||||
available: visualizerAvailable
|
||||
});
|
||||
}
|
||||
|
||||
function initVisualizerCanvas() {
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!canvas) return;
|
||||
visualizerCtx = canvas.getContext('2d');
|
||||
canvas.width = 300;
|
||||
canvas.height = 64;
|
||||
}
|
||||
|
||||
function startVisualizerRender() {
|
||||
if (visualizerAnimFrame) return;
|
||||
renderVisualizerFrame();
|
||||
}
|
||||
|
||||
function stopVisualizerRender() {
|
||||
if (visualizerAnimFrame) {
|
||||
cancelAnimationFrame(visualizerAnimFrame);
|
||||
visualizerAnimFrame = null;
|
||||
}
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (visualizerCtx && canvas) {
|
||||
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
const art = document.getElementById('album-art');
|
||||
if (art) {
|
||||
art.style.transform = '';
|
||||
art.style.removeProperty('--vinyl-scale');
|
||||
}
|
||||
const glow = document.getElementById('album-art-glow');
|
||||
if (glow) glow.style.opacity = '';
|
||||
frequencyData = null;
|
||||
smoothedFrequencies = null;
|
||||
}
|
||||
|
||||
function renderVisualizerFrame() {
|
||||
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
|
||||
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!frequencyData || !visualizerCtx || !canvas) return;
|
||||
|
||||
const bins = frequencyData.frequencies;
|
||||
const numBins = bins.length;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const gap = 2;
|
||||
const barWidth = (w / numBins) - gap;
|
||||
const accent = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim();
|
||||
|
||||
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
|
||||
smoothedFrequencies = new Array(numBins).fill(0);
|
||||
}
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
|
||||
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
|
||||
}
|
||||
|
||||
visualizerCtx.clearRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
|
||||
const x = i * (barWidth + gap) + gap / 2;
|
||||
const y = h - barHeight;
|
||||
|
||||
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
|
||||
grad.addColorStop(0, accent);
|
||||
grad.addColorStop(1, accent + '30');
|
||||
|
||||
visualizerCtx.fillStyle = grad;
|
||||
visualizerCtx.beginPath();
|
||||
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
|
||||
visualizerCtx.fill();
|
||||
}
|
||||
|
||||
const bass = frequencyData.bass || 0;
|
||||
const scale = 1 + bass * 0.04;
|
||||
const art = document.getElementById('album-art');
|
||||
if (art) {
|
||||
if (vinylMode) {
|
||||
art.style.setProperty('--vinyl-scale', scale);
|
||||
} else {
|
||||
art.style.transform = `scale(${scale})`;
|
||||
}
|
||||
}
|
||||
const glow = document.getElementById('album-art-glow');
|
||||
if (glow) {
|
||||
glow.style.opacity = (0.4 + bass * 0.4).toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Audio device selection
|
||||
async function loadAudioDevices() {
|
||||
const section = document.getElementById('audioDeviceSection');
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
if (!section || !select) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
const [devicesResp, statusResp] = await Promise.all([
|
||||
fetch('/api/media/visualizer/devices', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}),
|
||||
fetch('/api/media/visualizer/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
|
||||
if (!devicesResp.ok || !statusResp.ok) return;
|
||||
|
||||
const devices = await devicesResp.json();
|
||||
const status = await statusResp.json();
|
||||
|
||||
if (!status.available && devices.length === 0) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = '';
|
||||
|
||||
while (select.options.length > 1) select.remove(1);
|
||||
for (const dev of devices) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = dev.name;
|
||||
opt.textContent = dev.name;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
if (status.current_device) {
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
if (select.options[i].value === status.current_device) {
|
||||
select.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateAudioDeviceStatus(status);
|
||||
} catch (e) {
|
||||
section.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateAudioDeviceStatus(status) {
|
||||
const el = document.getElementById('audioDeviceStatus');
|
||||
if (!el) return;
|
||||
// Badge reflects local visualizer state (capture is on-demand per subscriber)
|
||||
if (visualizerEnabled && status.available) {
|
||||
el.className = 'audio-device-status active';
|
||||
el.textContent = t('settings.audio.status_active');
|
||||
} else if (status.available) {
|
||||
el.className = 'audio-device-status available';
|
||||
el.textContent = t('settings.audio.status_available');
|
||||
} else {
|
||||
el.className = 'audio-device-status unavailable';
|
||||
el.textContent = t('settings.audio.status_unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
async function onAudioDeviceChanged() {
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
if (!select) return;
|
||||
|
||||
const deviceName = select.value || null;
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/media/visualizer/device', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ device_name: deviceName })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const result = await resp.json();
|
||||
updateAudioDeviceStatus({ available: result.success, ...result });
|
||||
await checkVisualizerAvailability();
|
||||
if (visualizerEnabled) applyVisualizerMode();
|
||||
showToast(t('settings.audio.device_changed'), 'success');
|
||||
} else {
|
||||
showToast(t('settings.audio.device_change_failed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t('settings.audio.device_change_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UI State Updates
|
||||
// ============================================================
|
||||
|
||||
let lastArtworkKey = null;
|
||||
let currentArtworkBlobUrl = null;
|
||||
let lastPositionUpdate = 0;
|
||||
let lastPositionValue = 0;
|
||||
let interpolationInterval = null;
|
||||
|
||||
function setupProgressDrag(bar, fill) {
|
||||
let dragging = false;
|
||||
|
||||
function getPercent(clientX) {
|
||||
const rect = bar.getBoundingClientRect();
|
||||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
}
|
||||
|
||||
function updatePreview(percent) {
|
||||
fill.style.width = (percent * 100) + '%';
|
||||
}
|
||||
|
||||
function handleStart(clientX) {
|
||||
if (currentDuration <= 0) return;
|
||||
dragging = true;
|
||||
bar.classList.add('dragging');
|
||||
updatePreview(getPercent(clientX));
|
||||
}
|
||||
|
||||
function handleMove(clientX) {
|
||||
if (!dragging) return;
|
||||
updatePreview(getPercent(clientX));
|
||||
}
|
||||
|
||||
function handleEnd(clientX) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
bar.classList.remove('dragging');
|
||||
const percent = getPercent(clientX);
|
||||
seek(percent * currentDuration);
|
||||
}
|
||||
|
||||
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
|
||||
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
|
||||
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
|
||||
|
||||
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
|
||||
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
|
||||
document.addEventListener('touchend', (e) => {
|
||||
if (dragging) {
|
||||
const touch = e.changedTouches[0];
|
||||
handleEnd(touch.clientX);
|
||||
}
|
||||
});
|
||||
|
||||
bar.addEventListener('click', (e) => {
|
||||
if (currentDuration > 0) {
|
||||
seek(getPercent(e.clientX) * currentDuration);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(status) {
|
||||
lastStatus = status;
|
||||
|
||||
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
dom.trackTitle.textContent = status.title || fallbackTitle;
|
||||
dom.artist.textContent = status.artist || '';
|
||||
dom.album.textContent = status.album || '';
|
||||
|
||||
dom.miniTrackTitle.textContent = status.title || fallbackTitle;
|
||||
dom.miniArtist.textContent = status.artist || '';
|
||||
|
||||
const previousState = currentState;
|
||||
currentState = status.state;
|
||||
updatePlaybackState(status.state);
|
||||
|
||||
const altText = status.title && status.artist
|
||||
? `${status.artist} – ${status.title}`
|
||||
: status.title || t('player.no_media');
|
||||
dom.albumArt.alt = altText;
|
||||
dom.miniAlbumArt.alt = altText;
|
||||
|
||||
const artworkSource = status.album_art_url || null;
|
||||
const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;
|
||||
|
||||
if (artworkKey !== lastArtworkKey) {
|
||||
lastArtworkKey = artworkKey;
|
||||
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
|
||||
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
|
||||
if (artworkSource) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
fetch(`/api/media/artwork?_=${Date.now()}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
.then(r => r.ok ? r.blob() : null)
|
||||
.then(blob => {
|
||||
if (!blob) return;
|
||||
const oldBlobUrl = currentArtworkBlobUrl;
|
||||
const url = URL.createObjectURL(blob);
|
||||
currentArtworkBlobUrl = url;
|
||||
dom.albumArt.src = url;
|
||||
dom.miniAlbumArt.src = url;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
|
||||
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
||||
})
|
||||
.catch(err => console.error('Artwork fetch failed:', err));
|
||||
} else {
|
||||
if (currentArtworkBlobUrl) {
|
||||
URL.revokeObjectURL(currentArtworkBlobUrl);
|
||||
currentArtworkBlobUrl = null;
|
||||
}
|
||||
dom.albumArt.src = placeholderArt;
|
||||
dom.miniAlbumArt.src = placeholderArt;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||
}
|
||||
}
|
||||
|
||||
if (status.duration && status.position !== null) {
|
||||
currentDuration = status.duration;
|
||||
currentPosition = status.position;
|
||||
lastPositionUpdate = Date.now();
|
||||
lastPositionValue = status.position;
|
||||
updateProgress(status.position, status.duration);
|
||||
}
|
||||
|
||||
if (!isUserAdjustingVolume) {
|
||||
dom.volumeSlider.value = status.volume;
|
||||
dom.volumeDisplay.textContent = `${status.volume}%`;
|
||||
dom.miniVolumeSlider.value = status.volume;
|
||||
dom.miniVolumeDisplay.textContent = `${status.volume}%`;
|
||||
}
|
||||
|
||||
updateMuteIcon(status.muted);
|
||||
|
||||
const src = resolveMediaSource(status.source);
|
||||
dom.source.textContent = src ? src.name : t('player.unknown_source');
|
||||
dom.sourceIcon.innerHTML = src?.icon || '';
|
||||
|
||||
const hasMedia = status.state !== 'idle';
|
||||
dom.btnPlayPause.disabled = !hasMedia;
|
||||
dom.btnNext.disabled = !hasMedia;
|
||||
dom.btnPrevious.disabled = !hasMedia;
|
||||
dom.miniBtnPlayPause.disabled = !hasMedia;
|
||||
|
||||
if (status.state === 'playing' && previousState !== 'playing') {
|
||||
startPositionInterpolation();
|
||||
} else if (status.state !== 'playing' && previousState === 'playing') {
|
||||
stopPositionInterpolation();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlaybackState(state) {
|
||||
currentPlayState = state;
|
||||
switch(state) {
|
||||
case 'playing':
|
||||
dom.playbackState.textContent = t('state.playing');
|
||||
dom.stateIcon.innerHTML = SVG_PLAY;
|
||||
dom.playPauseIcon.innerHTML = SVG_PAUSE;
|
||||
dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE;
|
||||
break;
|
||||
case 'paused':
|
||||
dom.playbackState.textContent = t('state.paused');
|
||||
dom.stateIcon.innerHTML = SVG_PAUSE;
|
||||
dom.playPauseIcon.innerHTML = SVG_PLAY;
|
||||
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
|
||||
break;
|
||||
case 'stopped':
|
||||
dom.playbackState.textContent = t('state.stopped');
|
||||
dom.stateIcon.innerHTML = SVG_STOP;
|
||||
dom.playPauseIcon.innerHTML = SVG_PLAY;
|
||||
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
|
||||
break;
|
||||
default:
|
||||
dom.playbackState.textContent = t('state.idle');
|
||||
dom.stateIcon.innerHTML = SVG_IDLE;
|
||||
dom.playPauseIcon.innerHTML = SVG_PLAY;
|
||||
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
|
||||
}
|
||||
updateVinylSpin();
|
||||
}
|
||||
|
||||
function updateProgress(position, duration) {
|
||||
const percent = (position / duration) * 100;
|
||||
const widthStr = `${percent}%`;
|
||||
const currentStr = formatTime(position);
|
||||
const totalStr = formatTime(duration);
|
||||
const posRound = Math.round(position);
|
||||
const durRound = Math.round(duration);
|
||||
|
||||
dom.progressFill.style.width = widthStr;
|
||||
dom.currentTime.textContent = currentStr;
|
||||
dom.totalTime.textContent = totalStr;
|
||||
dom.progressBar.dataset.duration = duration;
|
||||
dom.progressBar.setAttribute('aria-valuenow', posRound);
|
||||
dom.progressBar.setAttribute('aria-valuemax', durRound);
|
||||
|
||||
dom.miniProgressFill.style.width = widthStr;
|
||||
dom.miniCurrentTime.textContent = currentStr;
|
||||
dom.miniTotalTime.textContent = totalStr;
|
||||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||||
const miniBar = document.getElementById('mini-progress-bar');
|
||||
miniBar.setAttribute('aria-valuenow', posRound);
|
||||
miniBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
|
||||
function startPositionInterpolation() {
|
||||
if (interpolationInterval) {
|
||||
clearInterval(interpolationInterval);
|
||||
}
|
||||
interpolationInterval = setInterval(() => {
|
||||
if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) {
|
||||
const elapsed = (Date.now() - lastPositionUpdate) / 1000;
|
||||
const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
|
||||
updateProgress(interpolatedPosition, currentDuration);
|
||||
}
|
||||
}, POSITION_INTERPOLATION_MS);
|
||||
}
|
||||
|
||||
function stopPositionInterpolation() {
|
||||
if (interpolationInterval) {
|
||||
clearInterval(interpolationInterval);
|
||||
interpolationInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMuteIcon(muted) {
|
||||
const path = muted ? SVG_MUTED : SVG_UNMUTED;
|
||||
dom.muteIcon.innerHTML = path;
|
||||
dom.miniMuteIcon.innerHTML = path;
|
||||
}
|
||||
537
media_server/static/js/scripts.js
Normal file
537
media_server/static/js/scripts.js
Normal file
@@ -0,0 +1,537 @@
|
||||
// ============================================================
|
||||
// Scripts: CRUD, quick access, execution dialog
|
||||
// ============================================================
|
||||
|
||||
let scriptFormDirty = false;
|
||||
|
||||
async function loadScripts() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scripts/list', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
scripts = await response.json();
|
||||
displayQuickAccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading scripts:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let _quickAccessGen = 0;
|
||||
async function displayQuickAccess() {
|
||||
const gen = ++_quickAccessGen;
|
||||
const grid = document.getElementById('scripts-grid');
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const hasScripts = scripts.length > 0;
|
||||
let hasLinks = false;
|
||||
|
||||
scripts.forEach(script => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'script-btn';
|
||||
button.onclick = () => executeScript(script.name, button);
|
||||
|
||||
if (script.icon) {
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'script-icon';
|
||||
iconEl.setAttribute('data-mdi-icon', script.icon);
|
||||
button.appendChild(iconEl);
|
||||
}
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'script-label';
|
||||
label.textContent = script.label || script.name;
|
||||
button.appendChild(label);
|
||||
|
||||
if (script.description) {
|
||||
const description = document.createElement('div');
|
||||
description.className = 'script-description';
|
||||
description.textContent = script.description;
|
||||
button.appendChild(description);
|
||||
}
|
||||
|
||||
fragment.appendChild(button);
|
||||
});
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (token) {
|
||||
const response = await fetch('/api/links/list', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (gen !== _quickAccessGen) return;
|
||||
if (response.ok) {
|
||||
const links = await response.json();
|
||||
hasLinks = links.length > 0;
|
||||
links.forEach(link => {
|
||||
const card = document.createElement('a');
|
||||
card.className = 'script-btn link-card';
|
||||
card.href = link.url;
|
||||
card.target = '_blank';
|
||||
card.rel = 'noopener noreferrer';
|
||||
|
||||
if (link.icon) {
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'script-icon';
|
||||
iconEl.setAttribute('data-mdi-icon', link.icon);
|
||||
card.appendChild(iconEl);
|
||||
}
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'script-label';
|
||||
label.textContent = link.label || link.name;
|
||||
card.appendChild(label);
|
||||
|
||||
if (link.description) {
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'script-description';
|
||||
desc.textContent = link.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen !== _quickAccessGen) return;
|
||||
console.warn('Failed to load links for quick access:', e);
|
||||
}
|
||||
|
||||
if (!hasScripts && !hasLinks) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'scripts-empty empty-state-illustration';
|
||||
empty.innerHTML = `<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('quick_access.no_items')}</p>`;
|
||||
fragment.prepend(empty);
|
||||
}
|
||||
|
||||
grid.innerHTML = '';
|
||||
grid.appendChild(fragment);
|
||||
resolveMdiIcons(grid);
|
||||
}
|
||||
|
||||
async function executeScript(scriptName, buttonElement) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
buttonElement.classList.add('executing');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ args: [] })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`${scriptName} executed successfully`, 'success');
|
||||
} else {
|
||||
showToast(`Failed to execute ${scriptName}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing script ${scriptName}:`, error);
|
||||
showToast(`Error executing ${scriptName}`, 'error');
|
||||
} finally {
|
||||
buttonElement.classList.remove('executing');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Script Management CRUD
|
||||
// ============================================================
|
||||
|
||||
let _loadScriptsPromise = null;
|
||||
async function loadScriptsTable() {
|
||||
if (_loadScriptsPromise) return _loadScriptsPromise;
|
||||
_loadScriptsPromise = _loadScriptsTableImpl();
|
||||
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
|
||||
return _loadScriptsPromise;
|
||||
}
|
||||
|
||||
async function _loadScriptsTableImpl() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const tbody = document.getElementById('scriptsTableBody');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scripts/list', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch scripts');
|
||||
}
|
||||
|
||||
const scriptsList = await response.json();
|
||||
|
||||
if (scriptsList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = scriptsList.map(script => `
|
||||
<tr>
|
||||
<td><span class="name-with-icon">${script.icon ? `<span class="table-icon" data-mdi-icon="${escapeHtml(script.icon)}"></span>` : ''}<code>${escapeHtml(script.name)}</code></span></td>
|
||||
<td>${escapeHtml(script.label || script.name)}</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
|
||||
<td>${script.timeout}s</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn execute" data-action="execute" data-script-name="${escapeHtml(script.name)}" title="Execute script">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn" data-action="edit" data-script-name="${escapeHtml(script.name)}" title="Edit script">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn delete" data-action="delete" data-script-name="${escapeHtml(script.name)}" title="Delete script">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading scripts:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function showAddScriptDialog() {
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
const form = document.getElementById('scriptForm');
|
||||
const title = document.getElementById('dialogTitle');
|
||||
|
||||
form.reset();
|
||||
document.getElementById('scriptOriginalName').value = '';
|
||||
document.getElementById('scriptIsEdit').value = 'false';
|
||||
document.getElementById('scriptName').disabled = false;
|
||||
document.getElementById('scriptIconPreview').innerHTML = '';
|
||||
title.textContent = t('scripts.dialog.add');
|
||||
|
||||
scriptFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditScriptDialog(scriptName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
const title = document.getElementById('dialogTitle');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scripts/list', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch script details');
|
||||
}
|
||||
|
||||
const scriptsList = await response.json();
|
||||
const script = scriptsList.find(s => s.name === scriptName);
|
||||
|
||||
if (!script) {
|
||||
showToast('Script not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('scriptOriginalName').value = scriptName;
|
||||
document.getElementById('scriptIsEdit').value = 'true';
|
||||
document.getElementById('scriptName').value = scriptName;
|
||||
document.getElementById('scriptName').disabled = true;
|
||||
document.getElementById('scriptLabel').value = script.label || '';
|
||||
document.getElementById('scriptCommand').value = script.command || '';
|
||||
document.getElementById('scriptDescription').value = script.description || '';
|
||||
document.getElementById('scriptIcon').value = script.icon || '';
|
||||
document.getElementById('scriptTimeout').value = script.timeout || 30;
|
||||
|
||||
const preview = document.getElementById('scriptIconPreview');
|
||||
if (script.icon) {
|
||||
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
|
||||
title.textContent = t('scripts.dialog.edit');
|
||||
scriptFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading script for edit:', error);
|
||||
showToast('Failed to load script details', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function closeScriptDialog() {
|
||||
if (scriptFormDirty) {
|
||||
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
scriptFormDirty = false;
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveScript(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const isEdit = document.getElementById('scriptIsEdit').value === 'true';
|
||||
const scriptName = isEdit ?
|
||||
document.getElementById('scriptOriginalName').value :
|
||||
document.getElementById('scriptName').value;
|
||||
|
||||
const data = {
|
||||
command: document.getElementById('scriptCommand').value,
|
||||
label: document.getElementById('scriptLabel').value || null,
|
||||
description: document.getElementById('scriptDescription').value || '',
|
||||
icon: document.getElementById('scriptIcon').value || null,
|
||||
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
|
||||
shell: true
|
||||
};
|
||||
|
||||
const endpoint = isEdit ?
|
||||
`/api/scripts/update/${scriptName}` :
|
||||
`/api/scripts/create/${scriptName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
scriptFormDirty = false;
|
||||
closeScriptDialog();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving script:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScriptConfirm(scriptName) {
|
||||
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Script deleted successfully', 'success');
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete script', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
showToast('Error deleting script', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Execution Result Dialog (shared by scripts and callbacks)
|
||||
// ============================================================
|
||||
|
||||
function closeExecutionDialog() {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
function showExecutionResult(name, result, type = 'script') {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
const outputSection = document.getElementById('outputSection');
|
||||
const errorSection = document.getElementById('errorSection');
|
||||
const outputPre = document.getElementById('executionOutput');
|
||||
const errorPre = document.getElementById('executionError');
|
||||
|
||||
title.textContent = `Execution Result: ${name}`;
|
||||
|
||||
const success = result.success && result.exit_code === 0;
|
||||
const statusClass = success ? 'success' : 'error';
|
||||
const statusText = success ? 'Success' : 'Failed';
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item ${statusClass}">
|
||||
<label>Status</label>
|
||||
<value>${statusText}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Exit Code</label>
|
||||
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Duration</label>
|
||||
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
||||
</div>
|
||||
`;
|
||||
|
||||
outputSection.style.display = 'block';
|
||||
if (result.stdout && result.stdout.trim()) {
|
||||
outputPre.textContent = result.stdout;
|
||||
} else {
|
||||
outputPre.textContent = '(no output)';
|
||||
outputPre.style.fontStyle = 'italic';
|
||||
outputPre.style.color = 'var(--text-secondary)';
|
||||
}
|
||||
|
||||
if (result.stderr && result.stderr.trim()) {
|
||||
errorSection.style.display = 'block';
|
||||
errorPre.textContent = result.stderr;
|
||||
errorPre.style.fontStyle = 'normal';
|
||||
errorPre.style.color = 'var(--error)';
|
||||
} else if (!success && result.error) {
|
||||
errorSection.style.display = 'block';
|
||||
errorPre.textContent = result.error;
|
||||
errorPre.style.fontStyle = 'normal';
|
||||
errorPre.style.color = 'var(--error)';
|
||||
} else {
|
||||
errorSection.style.display = 'none';
|
||||
}
|
||||
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function executeScriptDebug(scriptName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${scriptName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
document.getElementById('errorSection').style.display = 'none';
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ args: [] })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showExecutionResult(scriptName, result, 'script');
|
||||
} else {
|
||||
showExecutionResult(scriptName, {
|
||||
success: false,
|
||||
exit_code: -1,
|
||||
error: result.detail || 'Execution failed',
|
||||
stderr: result.detail || 'Unknown error'
|
||||
}, 'script');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing script ${scriptName}:`, error);
|
||||
showExecutionResult(scriptName, {
|
||||
success: false,
|
||||
exit_code: -1,
|
||||
error: error.message,
|
||||
stderr: `Network error: ${error.message}`
|
||||
}, 'script');
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCallbackDebug(callbackName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${callbackName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
document.getElementById('errorSection').style.display = 'none';
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showExecutionResult(callbackName, result, 'callback');
|
||||
} else {
|
||||
showExecutionResult(callbackName, {
|
||||
success: false,
|
||||
exit_code: -1,
|
||||
error: result.detail || 'Execution failed',
|
||||
stderr: result.detail || 'Unknown error'
|
||||
}, 'callback');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing callback ${callbackName}:`, error);
|
||||
showExecutionResult(callbackName, {
|
||||
success: false,
|
||||
exit_code: -1,
|
||||
error: error.message,
|
||||
stderr: `Network error: ${error.message}`
|
||||
}, 'callback');
|
||||
}
|
||||
}
|
||||
169
media_server/static/js/websocket.js
Normal file
169
media_server/static/js/websocket.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// ============================================================
|
||||
// WebSocket: Connection, reconnection, authentication
|
||||
// ============================================================
|
||||
|
||||
let reconnectTimeout = null;
|
||||
let pingInterval = null;
|
||||
let wsReconnectAttempts = 0;
|
||||
|
||||
function showAuthForm(errorMessage = '') {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
const errorEl = document.getElementById('auth-error');
|
||||
if (errorMessage) {
|
||||
errorEl.textContent = errorMessage;
|
||||
errorEl.classList.add('visible');
|
||||
} else {
|
||||
errorEl.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function hideAuthForm() {
|
||||
document.getElementById('auth-overlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
function authenticate() {
|
||||
const token = document.getElementById('token-input').value.trim();
|
||||
if (!token) {
|
||||
showAuthForm(t('auth.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('media_server_token', token);
|
||||
connectWebSocket(token);
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
localStorage.removeItem('media_server_token');
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
showAuthForm(t('auth.cleared'));
|
||||
}
|
||||
|
||||
function connectWebSocket(token) {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
wsReconnectAttempts = 0;
|
||||
updateConnectionStatus(true);
|
||||
hideConnectionBanner();
|
||||
hideAuthForm();
|
||||
loadScripts();
|
||||
loadScriptsTable();
|
||||
loadCallbacksTable();
|
||||
loadLinksTable();
|
||||
loadHeaderLinks();
|
||||
loadAudioDevices();
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||
updateUI(msg.data);
|
||||
} else if (msg.type === 'scripts_changed') {
|
||||
console.log('Scripts changed, reloading...');
|
||||
loadScripts();
|
||||
loadScriptsTable();
|
||||
} else if (msg.type === 'links_changed') {
|
||||
console.log('Links changed, reloading...');
|
||||
loadHeaderLinks();
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
} else if (msg.type === 'audio_data') {
|
||||
frequencyData = msg.data;
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('WebSocket error:', msg.message);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateConnectionStatus(false);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code);
|
||||
updateConnectionStatus(false);
|
||||
stopPositionInterpolation();
|
||||
|
||||
if (event.code === 4001) {
|
||||
localStorage.removeItem('media_server_token');
|
||||
showAuthForm(t('auth.invalid'));
|
||||
} else if (event.code !== 1000) {
|
||||
wsReconnectAttempts++;
|
||||
|
||||
if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
|
||||
const delay = Math.min(
|
||||
WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1),
|
||||
WS_BACKOFF_MAX_MS
|
||||
);
|
||||
console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`);
|
||||
|
||||
if (wsReconnectAttempts >= 3) {
|
||||
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
||||
}
|
||||
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken) {
|
||||
connectWebSocket(savedToken);
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
showConnectionBanner(t('connection.lost'), true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function updateConnectionStatus(connected) {
|
||||
if (connected) {
|
||||
dom.statusDot.classList.add('connected');
|
||||
} else {
|
||||
dom.statusDot.classList.remove('connected');
|
||||
}
|
||||
}
|
||||
|
||||
function showConnectionBanner(message, showButton) {
|
||||
const banner = document.getElementById('connectionBanner');
|
||||
const text = document.getElementById('connectionBannerText');
|
||||
const btn = document.getElementById('connectionBannerBtn');
|
||||
text.textContent = message;
|
||||
btn.style.display = showButton ? '' : 'none';
|
||||
banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideConnectionBanner() {
|
||||
const banner = document.getElementById('connectionBanner');
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
|
||||
function manualReconnect() {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken) {
|
||||
wsReconnectAttempts = 0;
|
||||
hideConnectionBanner();
|
||||
connectWebSocket(savedToken);
|
||||
}
|
||||
}
|
||||
219
media_server/static/locales/en.json
Normal file
219
media_server/static/locales/en.json
Normal file
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"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",
|
||||
"accent.custom": "Custom",
|
||||
"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.title_unavailable": "Title unavailable",
|
||||
"player.source": "Source:",
|
||||
"player.unknown_source": "Unknown",
|
||||
"player.vinyl": "Vinyl mode",
|
||||
"player.visualizer": "Audio visualizer",
|
||||
"player.background": "Dynamic background",
|
||||
"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.execution.title": "Execution Result",
|
||||
"scripts.execution.output": "Output",
|
||||
"scripts.execution.error_output": "Error Output",
|
||||
"scripts.execution.close": "Close",
|
||||
"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?",
|
||||
"tab.player": "Player",
|
||||
"tab.browser": "Browser",
|
||||
"tab.quick_access": "Quick Access",
|
||||
"tab.settings": "Settings",
|
||||
"tab.display": "Display",
|
||||
"settings.section.scripts": "Scripts",
|
||||
"settings.section.callbacks": "Callbacks",
|
||||
"settings.section.links": "Links",
|
||||
"settings.section.audio": "Audio",
|
||||
"settings.audio.description": "Select which audio output device to capture for the visualizer.",
|
||||
"settings.audio.device": "Loopback Device",
|
||||
"settings.audio.auto": "Auto-detect",
|
||||
"settings.audio.status_active": "Capturing audio",
|
||||
"settings.audio.status_available": "Available, not capturing",
|
||||
"settings.audio.status_unavailable": "Unavailable",
|
||||
"settings.audio.device_changed": "Audio device changed",
|
||||
"settings.audio.device_change_failed": "Failed to change audio device",
|
||||
"quick_access.no_items": "No quick actions or links configured",
|
||||
"display.loading": "Loading monitors...",
|
||||
"display.error": "Failed to load monitors",
|
||||
"display.no_monitors": "No monitors detected",
|
||||
"display.power_on": "Turn on",
|
||||
"display.power_off": "Turn off",
|
||||
"display.primary": "Primary",
|
||||
"browser.title": "Media Browser",
|
||||
"browser.home": "Home",
|
||||
"browser.manage_folders": "Manage Folders",
|
||||
"browser.select_folder": "Select a folder...",
|
||||
"browser.select_folder_option": "Select a folder...",
|
||||
"browser.no_folder_selected": "Select a folder to browse media files",
|
||||
"browser.no_items": "No media files found in this folder",
|
||||
"browser.view_grid": "Grid view",
|
||||
"browser.view_compact": "Compact view",
|
||||
"browser.view_list": "List view",
|
||||
"browser.search": "Search...",
|
||||
"browser.items_per_page": "Items per page:",
|
||||
"browser.page": "Page",
|
||||
"browser.previous": "Previous",
|
||||
"browser.next": "Next",
|
||||
"browser.download": "Download",
|
||||
"browser.play_success": "Playing {filename}",
|
||||
"browser.play_error": "Failed to play file",
|
||||
"browser.play_all": "Play All",
|
||||
"browser.play_all_success": "Playing {count} files",
|
||||
"browser.play_all_error": "Failed to play folder",
|
||||
"browser.error_loading": "Error loading directory",
|
||||
"browser.error_loading_folders": "Failed to load media folders",
|
||||
"browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.",
|
||||
"browser.folder_dialog.title_add": "Add Media Folder",
|
||||
"browser.folder_dialog.title_edit": "Edit Media Folder",
|
||||
"browser.folder_dialog.folder_id": "Folder ID *",
|
||||
"browser.folder_dialog.folder_id_help": "Alphanumeric and underscore only",
|
||||
"browser.folder_dialog.label": "Label *",
|
||||
"browser.folder_dialog.label_help": "Display name for this folder",
|
||||
"browser.folder_dialog.path": "Path *",
|
||||
"browser.folder_dialog.path_help": "Absolute path to media directory",
|
||||
"browser.folder_dialog.enabled": "Enabled",
|
||||
"browser.folder_dialog.cancel": "Cancel",
|
||||
"browser.folder_dialog.save": "Save",
|
||||
"browser.download_error": "Failed to download file",
|
||||
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
|
||||
"connection.lost": "Connection lost. Server may be unavailable.",
|
||||
"connection.reconnect": "Reconnect",
|
||||
"dialog.cancel": "Cancel",
|
||||
"dialog.confirm": "Confirm",
|
||||
"links.description": "Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.",
|
||||
"links.empty": "No links configured. Click 'Add' to create one.",
|
||||
"links.table.name": "Name",
|
||||
"links.table.url": "URL",
|
||||
"links.table.label": "Label",
|
||||
"links.table.actions": "Actions",
|
||||
"links.dialog.add": "Add Link",
|
||||
"links.dialog.edit": "Edit Link",
|
||||
"links.field.name": "Link Name *",
|
||||
"links.field.url": "URL *",
|
||||
"links.field.icon": "Icon (MDI)",
|
||||
"links.field.label": "Label",
|
||||
"links.field.description": "Description",
|
||||
"links.placeholder.name": "Only letters, numbers, and underscores allowed",
|
||||
"links.placeholder.url": "https://example.com",
|
||||
"links.placeholder.icon": "mdi:link",
|
||||
"links.placeholder.label": "Tooltip text",
|
||||
"links.placeholder.description": "What does this link point to?",
|
||||
"links.button.cancel": "Cancel",
|
||||
"links.button.save": "Save",
|
||||
"links.button.edit": "Edit",
|
||||
"links.button.delete": "Delete",
|
||||
"links.msg.created": "Link created successfully",
|
||||
"links.msg.updated": "Link updated successfully",
|
||||
"links.msg.create_failed": "Failed to create link",
|
||||
"links.msg.update_failed": "Failed to update link",
|
||||
"links.msg.deleted": "Link deleted successfully",
|
||||
"links.msg.delete_failed": "Failed to delete link",
|
||||
"links.msg.not_found": "Link not found",
|
||||
"links.msg.load_failed": "Failed to load link details",
|
||||
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
||||
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"footer.created_by": "Created by",
|
||||
"footer.source_code": "Source Code"
|
||||
}
|
||||
219
media_server/static/locales/ru.json
Normal file
219
media_server/static/locales/ru.json
Normal file
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"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": "Переключить тему",
|
||||
"accent.custom": "Свой цвет",
|
||||
"player.locale": "Изменить язык",
|
||||
"player.previous": "Предыдущий",
|
||||
"player.play": "Воспроизвести/Пауза",
|
||||
"player.next": "Следующий",
|
||||
"player.mute": "Без звука",
|
||||
"player.status.connected": "Подключено",
|
||||
"player.status.disconnected": "Отключено",
|
||||
"player.no_media": "Медиа не воспроизводится",
|
||||
"player.title_unavailable": "Название недоступно",
|
||||
"player.source": "Источник:",
|
||||
"player.unknown_source": "Неизвестно",
|
||||
"player.vinyl": "Режим винила",
|
||||
"player.visualizer": "Аудио визуализатор",
|
||||
"player.background": "Динамический фон",
|
||||
"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.execution.title": "Результат выполнения",
|
||||
"scripts.execution.output": "Вывод",
|
||||
"scripts.execution.error_output": "Вывод ошибок",
|
||||
"scripts.execution.close": "Закрыть",
|
||||
"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": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"tab.player": "Плеер",
|
||||
"tab.browser": "Браузер",
|
||||
"tab.quick_access": "Быстрый Доступ",
|
||||
"tab.settings": "Настройки",
|
||||
"tab.display": "Дисплей",
|
||||
"settings.section.scripts": "Скрипты",
|
||||
"settings.section.callbacks": "Колбэки",
|
||||
"settings.section.links": "Ссылки",
|
||||
"settings.section.audio": "Аудио",
|
||||
"settings.audio.description": "Выберите аудиоустройство для захвата звука визуализатора.",
|
||||
"settings.audio.device": "Устройство захвата",
|
||||
"settings.audio.auto": "Автоопределение",
|
||||
"settings.audio.status_active": "Захват аудио",
|
||||
"settings.audio.status_available": "Доступно, не захватывает",
|
||||
"settings.audio.status_unavailable": "Недоступно",
|
||||
"settings.audio.device_changed": "Аудиоустройство изменено",
|
||||
"settings.audio.device_change_failed": "Не удалось изменить аудиоустройство",
|
||||
"quick_access.no_items": "Быстрые действия и ссылки не настроены",
|
||||
"display.loading": "Загрузка мониторов...",
|
||||
"display.error": "Не удалось загрузить мониторы",
|
||||
"display.no_monitors": "Мониторы не обнаружены",
|
||||
"display.power_on": "Включить",
|
||||
"display.power_off": "Выключить",
|
||||
"display.primary": "Основной",
|
||||
"browser.title": "Медиа Браузер",
|
||||
"browser.home": "Главная",
|
||||
"browser.manage_folders": "Управление папками",
|
||||
"browser.select_folder": "Выберите папку...",
|
||||
"browser.select_folder_option": "Выберите папку...",
|
||||
"browser.no_folder_selected": "Выберите папку для просмотра медиафайлов",
|
||||
"browser.no_items": "В этой папке не найдено медиафайлов",
|
||||
"browser.view_grid": "Сетка",
|
||||
"browser.view_compact": "Компактный вид",
|
||||
"browser.view_list": "Список",
|
||||
"browser.search": "Поиск...",
|
||||
"browser.items_per_page": "Элементов на странице:",
|
||||
"browser.page": "Страница",
|
||||
"browser.previous": "Предыдущая",
|
||||
"browser.next": "Следующая",
|
||||
"browser.download": "Скачать",
|
||||
"browser.play_success": "Воспроизведение {filename}",
|
||||
"browser.play_error": "Не удалось воспроизвести файл",
|
||||
"browser.play_all": "Воспроизвести все",
|
||||
"browser.play_all_success": "Воспроизведение {count} файлов",
|
||||
"browser.play_all_error": "Не удалось воспроизвести папку",
|
||||
"browser.error_loading": "Ошибка загрузки каталога",
|
||||
"browser.error_loading_folders": "Не удалось загрузить медиа папки",
|
||||
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.",
|
||||
"browser.folder_dialog.title_add": "Добавить медиа папку",
|
||||
"browser.folder_dialog.title_edit": "Редактировать медиа папку",
|
||||
"browser.folder_dialog.folder_id": "ID папки *",
|
||||
"browser.folder_dialog.folder_id_help": "Только буквы, цифры и подчеркивание",
|
||||
"browser.folder_dialog.label": "Метка *",
|
||||
"browser.folder_dialog.label_help": "Отображаемое имя папки",
|
||||
"browser.folder_dialog.path": "Путь *",
|
||||
"browser.folder_dialog.path_help": "Абсолютный путь к медиа каталогу",
|
||||
"browser.folder_dialog.enabled": "Включено",
|
||||
"browser.folder_dialog.cancel": "Отмена",
|
||||
"browser.folder_dialog.save": "Сохранить",
|
||||
"browser.download_error": "Не удалось скачать файл",
|
||||
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
|
||||
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
|
||||
"connection.reconnect": "Переподключиться",
|
||||
"dialog.cancel": "Отмена",
|
||||
"dialog.confirm": "Подтвердить",
|
||||
"links.description": "Быстрые ссылки, отображаемые в виде иконок в шапке. Нажмите на иконку, чтобы открыть URL в новой вкладке.",
|
||||
"links.empty": "Ссылки не настроены. Нажмите 'Добавить' для создания.",
|
||||
"links.table.name": "Имя",
|
||||
"links.table.url": "URL",
|
||||
"links.table.label": "Метка",
|
||||
"links.table.actions": "Действия",
|
||||
"links.dialog.add": "Добавить Ссылку",
|
||||
"links.dialog.edit": "Редактировать Ссылку",
|
||||
"links.field.name": "Имя Ссылки *",
|
||||
"links.field.url": "URL *",
|
||||
"links.field.icon": "Иконка (MDI)",
|
||||
"links.field.label": "Метка",
|
||||
"links.field.description": "Описание",
|
||||
"links.placeholder.name": "Только буквы, цифры и подчеркивания",
|
||||
"links.placeholder.url": "https://example.com",
|
||||
"links.placeholder.icon": "mdi:link",
|
||||
"links.placeholder.label": "Текст подсказки",
|
||||
"links.placeholder.description": "Куда ведет эта ссылка?",
|
||||
"links.button.cancel": "Отмена",
|
||||
"links.button.save": "Сохранить",
|
||||
"links.button.edit": "Редактировать",
|
||||
"links.button.delete": "Удалить",
|
||||
"links.msg.created": "Ссылка создана успешно",
|
||||
"links.msg.updated": "Ссылка обновлена успешно",
|
||||
"links.msg.create_failed": "Не удалось создать ссылку",
|
||||
"links.msg.update_failed": "Не удалось обновить ссылку",
|
||||
"links.msg.deleted": "Ссылка удалена успешно",
|
||||
"links.msg.delete_failed": "Не удалось удалить ссылку",
|
||||
"links.msg.not_found": "Ссылка не найдена",
|
||||
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
|
||||
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"footer.created_by": "Создано",
|
||||
"footer.source_code": "Исходный код"
|
||||
}
|
||||
23
media_server/static/manifest.json
Normal file
23
media_server/static/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Media Server",
|
||||
"short_name": "Media",
|
||||
"description": "Remote media player control and file browser",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#121212",
|
||||
"theme_color": "#121212",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
media_server/static/sw.js
Normal file
15
media_server/static/sw.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Minimal service worker for PWA installability.
|
||||
// This app requires a live WebSocket connection, so offline caching is not useful.
|
||||
// All fetch requests are passed through to the network.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
@@ -30,6 +30,8 @@ dependencies = [
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
"pyyaml>=6.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=10.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -38,6 +40,12 @@ windows = [
|
||||
"pywin32>=306",
|
||||
"comtypes>=1.2.0",
|
||||
"pycaw>=20230407",
|
||||
"screen-brightness-control>=0.20.0",
|
||||
"monitorcontrol>=3.0.0",
|
||||
]
|
||||
visualizer = [
|
||||
"soundcard>=0.4.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
|
||||
19
scripts/install_task_windows.ps1
Normal file
19
scripts/install_task_windows.ps1
Normal file
@@ -0,0 +1,19 @@
|
||||
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false -ErrorAction SilentlyContinue
|
||||
|
||||
# Get the media-server directory (parent of scripts folder)
|
||||
$serverRoot = (Get-Item $PSScriptRoot).Parent.FullName
|
||||
$vbsPath = Join-Path $PSScriptRoot "start-hidden.vbs"
|
||||
|
||||
if (-not (Test-Path $vbsPath)) {
|
||||
Write-Error "start-hidden.vbs not found in scripts folder."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Launch via wscript + VBS to run python completely hidden (no console window)
|
||||
$action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$vbsPath`"" -WorkingDirectory $serverRoot
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogon -User "$env:USERNAME"
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType Interactive -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant"
|
||||
|
||||
Write-Host "Scheduled task 'MediaServer' created with working directory: $serverRoot"
|
||||
35
scripts/restart-server.ps1
Normal file
35
scripts/restart-server.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
# Restart the Media Server
|
||||
# Stop any running instance
|
||||
$procs = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
||||
foreach ($p in $procs) {
|
||||
Write-Host "Stopping server (PID $($p.Id))..."
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($procs) { Start-Sleep -Seconds 2 }
|
||||
|
||||
# Merge registry PATH with current PATH so newly-installed tools are visible
|
||||
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($regUser) {
|
||||
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
||||
foreach ($dir in ($regUser -split ';')) {
|
||||
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
|
||||
$env:PATH = "$env:PATH;$dir"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Start server detached
|
||||
Write-Host "Starting server..."
|
||||
Start-Process -FilePath 'media-server' `
|
||||
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
|
||||
-WindowStyle Hidden
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# Verify it's running
|
||||
$check = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
||||
if ($check) {
|
||||
Write-Host "Server started (PID $($check[0].Id))"
|
||||
} else {
|
||||
Write-Host "WARNING: Server does not appear to be running!"
|
||||
}
|
||||
7
scripts/start-hidden.vbs
Normal file
7
scripts/start-hidden.vbs
Normal file
@@ -0,0 +1,7 @@
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
' Get the directory of this script (scripts\), then go up to media-server root
|
||||
scriptDir = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
|
||||
serverRoot = CreateObject("Scripting.FileSystemObject").GetParentFolderName(scriptDir)
|
||||
WshShell.CurrentDirectory = serverRoot
|
||||
' Run python completely hidden (0 = hidden, False = don't wait)
|
||||
WshShell.Run "python -m media_server.main", 0, False
|
||||
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