Update media-server: Add execution timing and improve script/callback execution UI

Backend improvements:
- Add execution_time tracking for script execution
- Add execution_time tracking for callback execution
- Add /api/callbacks/execute/{callback_name} endpoint for debugging callbacks

Frontend improvements:
- Fix duration display showing N/A for fast scripts (0 is falsy in JS)
- Increase duration precision to 3 decimal places (0.001s)
- Always show output section with "(no output)" message when empty
- Improve output formatting with italic gray text for empty output

Documentation:
- Add localization section to README
- Document available languages (English, Russian)
- Add guide for contributing new translations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 17:20:51 +03:00
parent 957a177b72
commit 4635caca98
4 changed files with 490 additions and 9 deletions

View File

@@ -29,6 +29,7 @@ The media server includes a built-in web interface for controlling and monitorin
- **Token authentication** - Saved in browser localStorage - **Token authentication** - Saved in browser localStorage
- **Responsive design** - Works on desktop and mobile - **Responsive design** - Works on desktop and mobile
- **Dark theme** - Easy on the eyes - **Dark theme** - Easy on the eyes
- **Multi-language support** - English and Russian locales with automatic detection
### Accessing the Web UI ### Accessing the Web UI
@@ -56,6 +57,38 @@ To access the Web UI from other devices on your network:
**Security Note:** For remote access over the internet, use a reverse proxy with HTTPS (nginx, Caddy) to encrypt traffic. **Security Note:** For remote access over the internet, use a reverse proxy with HTTPS (nginx, Caddy) to encrypt traffic.
### Localization
The Web UI supports multiple languages with automatic browser locale detection:
**Available Languages:**
- **English (en)** - Default
- **Русский (ru)** - Russian
The interface automatically detects your browser language on first visit. You can manually switch languages using the dropdown in the top-right corner of the Web UI.
**Contributing New Locales:**
We welcome translations for additional languages! To contribute a new locale:
1. Copy `media_server/static/locales/en.json` to a new file named with your language code (e.g., `de.json` for German)
2. Translate all strings to your language, keeping the same JSON structure
3. Add your language to the `supportedLocales` object in `media_server/static/index.html`:
```javascript
const supportedLocales = {
'en': 'English',
'ru': 'Русский',
'de': 'Deutsch' // Add your language here
};
```
4. Test the translation by switching to your language in the Web UI
5. Submit a pull request with your changes
See [CLAUDE.md](CLAUDE.md#internationalization-i18n) for detailed translation guidelines.
## Requirements ## Requirements
- Python 3.10+ - Python 3.10+

View File

@@ -1,7 +1,10 @@
"""Callback management API endpoints.""" """Callback management API endpoints."""
import asyncio
import logging import logging
import re import re
import subprocess
import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -34,6 +37,18 @@ class CallbackCreateRequest(BaseModel):
shell: bool = Field(default=True, description="Run command in shell") 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: def _validate_callback_name(name: str) -> None:
"""Validate callback name. """Validate callback name.
@@ -84,6 +99,116 @@ async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
] ]
@router.post("/execute/{callback_name}")
async def execute_callback(
callback_name: str,
_: str = Depends(verify_token),
) -> CallbackExecuteResponse:
"""Execute a callback for debugging purposes.
Args:
callback_name: Name of the callback to execute
Returns:
Execution result including stdout, stderr, and exit code
"""
# Validate callback name
_validate_callback_name(callback_name)
# Check if callback exists
if callback_name not in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Callback '{callback_name}' not found. Use /api/callbacks/list to see configured callbacks.",
)
callback_config = settings.callbacks[callback_name]
logger.info(f"Executing callback for debugging: {callback_name}")
try:
# Execute in thread pool to not block
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: _run_callback(
command=callback_config.command,
timeout=callback_config.timeout,
shell=callback_config.shell,
working_dir=callback_config.working_dir,
),
)
return CallbackExecuteResponse(
success=result["exit_code"] == 0,
callback=callback_name,
exit_code=result["exit_code"],
stdout=result["stdout"],
stderr=result["stderr"],
execution_time=result.get("execution_time"),
)
except Exception as e:
logger.error(f"Callback execution error: {e}")
return CallbackExecuteResponse(
success=False,
callback=callback_name,
error=str(e),
)
def _run_callback(
command: str,
timeout: int,
shell: bool,
working_dir: str | None,
) -> dict[str, Any]:
"""Run a callback synchronously.
Args:
command: Command to execute
timeout: Timeout in seconds
shell: Whether to run in shell
working_dir: Working directory
Returns:
Dict with exit_code, stdout, stderr, execution_time
"""
start_time = time.time()
try:
result = subprocess.run(
command,
shell=shell,
cwd=working_dir,
capture_output=True,
text=True,
timeout=timeout,
)
execution_time = time.time() - start_time
return {
"exit_code": result.returncode,
"stdout": result.stdout[:10000], # Limit output size
"stderr": result.stderr[:10000],
"execution_time": execution_time,
}
except subprocess.TimeoutExpired:
execution_time = time.time() - start_time
return {
"exit_code": -1,
"stdout": "",
"stderr": f"Callback timed out after {timeout} seconds",
"execution_time": execution_time,
}
except Exception as e:
execution_time = time.time() - start_time
return {
"exit_code": -1,
"stdout": "",
"stderr": str(e),
"execution_time": execution_time,
}
@router.post("/create/{callback_name}") @router.post("/create/{callback_name}")
async def create_callback( async def create_callback(
callback_name: str, callback_name: str,

View File

@@ -4,6 +4,7 @@ import asyncio
import logging import logging
import re import re
import subprocess import subprocess
import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -33,6 +34,7 @@ class ScriptExecuteResponse(BaseModel):
stdout: str = "" stdout: str = ""
stderr: str = "" stderr: str = ""
error: str | None = None error: str | None = None
execution_time: float | None = None
class ScriptInfo(BaseModel): class ScriptInfo(BaseModel):
@@ -117,6 +119,7 @@ async def execute_script(
exit_code=result["exit_code"], exit_code=result["exit_code"],
stdout=result["stdout"], stdout=result["stdout"],
stderr=result["stderr"], stderr=result["stderr"],
execution_time=result.get("execution_time"),
) )
except Exception as e: except Exception as e:
@@ -143,8 +146,9 @@ def _run_script(
working_dir: Working directory working_dir: Working directory
Returns: Returns:
Dict with exit_code, stdout, stderr Dict with exit_code, stdout, stderr, execution_time
""" """
start_time = time.time()
try: try:
result = subprocess.run( result = subprocess.run(
command, command,
@@ -154,22 +158,28 @@ def _run_script(
text=True, text=True,
timeout=timeout, timeout=timeout,
) )
execution_time = time.time() - start_time
return { return {
"exit_code": result.returncode, "exit_code": result.returncode,
"stdout": result.stdout[:10000], # Limit output size "stdout": result.stdout[:10000], # Limit output size
"stderr": result.stderr[:10000], "stderr": result.stderr[:10000],
"execution_time": execution_time,
} }
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
execution_time = time.time() - start_time
return { return {
"exit_code": -1, "exit_code": -1,
"stdout": "", "stdout": "",
"stderr": f"Script timed out after {timeout} seconds", "stderr": f"Script timed out after {timeout} seconds",
"execution_time": execution_time,
} }
except Exception as e: except Exception as e:
execution_time = time.time() - start_time
return { return {
"exit_code": -1, "exit_code": -1,
"stdout": "", "stdout": "",
"stderr": str(e), "stderr": str(e),
"execution_time": execution_time,
} }

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Media Server</title> <title>Media Server</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%231db954;stop-opacity:1' /%3E%3Cstop offset='100%25' style='stop-color:%231ed760;stop-opacity:1' /%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23grad)'/%3E%3Cpath fill='white' d='M35 25 L35 75 L75 50 Z'/%3E%3C/svg%3E">
<style> <style>
:root { :root {
--bg-primary: #121212; --bg-primary: #121212;
@@ -465,20 +466,30 @@
} }
.action-btn { .action-btn {
padding: 0.25rem 0.75rem; padding: 0.5rem;
margin-right: 0.5rem; border-radius: 6px;
border-radius: 4px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s; transition: all 0.2s;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-btn svg {
width: 16px;
height: 16px;
fill: currentColor;
} }
.action-btn:hover { .action-btn:hover {
background: var(--accent); background: var(--accent);
border-color: var(--accent); border-color: var(--accent);
transform: translateY(-1px);
} }
.action-btn.delete:hover { .action-btn.delete:hover {
@@ -486,6 +497,95 @@
border-color: var(--error); border-color: var(--error);
} }
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.action-btn.execute:hover {
background: #3b82f6;
border-color: #3b82f6;
}
/* Execution Result Dialog */
.execution-result {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
background: var(--bg-primary);
border-radius: 6px;
padding: 1rem;
margin: 0.5rem 0;
max-height: 400px;
overflow-y: auto;
}
.execution-result pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 0.813rem;
line-height: 1.5;
}
.execution-status {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-item label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
font-weight: 600;
}
.status-item value {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
.status-item.success value {
color: var(--accent);
}
.status-item.error value {
color: var(--error);
}
.result-section {
margin-bottom: 1rem;
}
.result-section h4 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-weight: 600;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid var(--bg-tertiary);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Dialog Styles */ /* Dialog Styles */
dialog { dialog {
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -1028,6 +1128,31 @@
</form> </form>
</dialog> </dialog>
<!-- Execution Result Dialog -->
<dialog id="executionDialog">
<div class="dialog-header">
<h3 id="executionDialogTitle">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>Output</h4>
<div class="execution-result">
<pre id="executionOutput"></pre>
</div>
</div>
<div class="result-section" id="errorSection" style="display: none;">
<h4>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()">Close</button>
</div>
</dialog>
<!-- Toast Notification --> <!-- Toast Notification -->
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
@@ -1740,8 +1865,17 @@
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td> title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
<td>${script.timeout}s</td> <td>${script.timeout}s</td>
<td> <td>
<button class="action-btn" onclick="showEditScriptDialog('${script.name}')">Edit</button> <div class="action-buttons">
<button class="action-btn delete" onclick="deleteScriptConfirm('${script.name}')">Delete</button> <button class="action-btn execute" onclick="executeScriptDebug('${script.name}')" title="Execute script">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" onclick="showEditScriptDialog('${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" onclick="deleteScriptConfirm('${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> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -1942,8 +2076,17 @@
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td> title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
<td>${callback.timeout}s</td> <td>${callback.timeout}s</td>
<td> <td>
<button class="action-btn" onclick="showEditCallbackDialog('${callback.name}')">Edit</button> <div class="action-buttons">
<button class="action-btn delete" onclick="deleteCallbackConfirm('${callback.name}')">Delete</button> <button class="action-btn execute" onclick="executeCallbackDebug('${callback.name}')" title="Execute callback">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" onclick="showEditCallbackDialog('${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" onclick="deleteCallbackConfirm('${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> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -2100,6 +2243,176 @@
showToast('Error deleting callback', 'error'); showToast('Error deleting callback', 'error');
} }
} }
// Execution Result Dialog Functions
function closeExecutionDialog() {
const dialog = document.getElementById('executionDialog');
dialog.close();
}
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');
// Set title
title.textContent = `Execution Result: ${name}`;
// Build status display
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>
`;
// Always show output section
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)';
}
// Show error output if present
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');
// Show dialog with loading state
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';
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');
// Show dialog with loading state
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';
dialog.showModal();
try {
// For callbacks, we'll execute them directly via the callback endpoint
// We need to trigger the callback as if the event occurred
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');
}
}
</script> </script>
</body> </body>
</html> </html>