Compare commits

..

6 Commits

Author SHA1 Message Date
29e0618b9f Update Web UI: Add server management scripts and improve UX
UI improvements:
- Add icon-based Execute/Edit/Delete buttons for scripts and callbacks
- Add execution result dialog with stdout/stderr and execution time
- Add favicon with media player icon
- Disable background scrolling when dialogs are open
- Add footer with author information and source code link

Backend enhancements:
- Add execution time tracking to script and callback execution
- Add /api/callbacks/execute endpoint for debugging callbacks
- Return detailed execution results (stdout, stderr, exit_code, execution_time)

Server management:
- Add scripts/start-server.bat - Start server with console window
- Add scripts/start-server-background.vbs - Start server silently
- Add scripts/stop-server.bat - Stop running server instances
- Add scripts/restart-server.bat - Restart the server

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 18:18:59 +03:00
4f8f59dc89 Update media-server: Fix FOUC (Flash of Untranslated Content) issues
- Add CSS to hide page content during translation load (opacity transition)
- Hide dialogs explicitly until opened with showModal()
- Hide auth overlay by default in HTML (shown only when needed)
- Prevents flash of English text, dialogs, and auth overlay on page load
- Smooth fade-in after translations are loaded

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 17:52:44 +03:00
40c2c11c85 Update media-server: Improve script/callback table layout and command editor UX
- Reduce command column max-width from 300px/400px to 200px for better table fit
- Change command input from single-line text to multiline textarea (3 rows)
- Apply changes to both script and callback dialogs
- Prevents table overflow and makes editing long commands easier

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 17:34:11 +03:00
0470a17a0c Update CLAUDE.md: Add server restart guidelines for development
- Add new "Development Workflow" section
- Document when server restart is required (Python/API changes)
- Document when restart is NOT needed (static files, docs)
- Provide step-by-step restart instructions for Windows and Linux/macOS
- Add best practice to verify changes after restart before pushing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 17:24:30 +03:00
4635caca98 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>
2026-02-06 17:20:51 +03:00
957a177b72 Update media-server: Add backdrop click-to-close for dialogs
- Add dirty state tracking for script and callback forms
- Add backdrop click event listeners to detect clicks outside dialogs
- Add unsaved changes confirmation when closing dialogs with modifications
- Reset dirty state when opening dialogs or after successful save
- Add localized confirmation messages (EN/RU) for unsaved changes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 14:54:42 +03:00
11 changed files with 839 additions and 78 deletions

View File

@@ -26,6 +26,55 @@ To remove the scheduled task:
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
``` ```
## Development Workflow
### Server Restart After Code Changes
**CRITICAL:** When making changes to backend code (Python files, API routes, service logic), the media server MUST be restarted for changes to take effect.
**When to restart:**
- Changes to any Python files (`*.py`) in the media_server directory
- Changes to API endpoints, routes, or request/response models
- Changes to service logic, callbacks, or script execution
- Changes to configuration handling or startup logic
**When restart is NOT needed:**
- Static file changes (`*.html`, `*.css`, `*.js`, `*.json`) - browser refresh is enough
- README or documentation updates
- Changes to install/service scripts (only affects new installations)
**How to restart during development:**
1. Find the running server process:
```bash
# Windows
netstat -ano | findstr :8765
# Linux/macOS
lsof -i :8765
```
2. Stop the server:
```bash
# Windows
taskkill //F //PID <process_id>
# Linux/macOS
kill <process_id>
```
3. Start the server again:
```bash
python -m media_server.main
```
**Best Practice:** Always restart the server immediately after committing backend changes to verify they work correctly before pushing.
## Configuration ## Configuration
Copy `config.example.yaml` to `config.yaml` and customize. Copy `config.example.yaml` to `config.yaml` and customize.

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;
@@ -44,6 +45,21 @@
line-height: 1.6; line-height: 1.6;
} }
/* Prevent flash of untranslated content */
body.loading-translations {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.translations-loaded {
opacity: 1;
}
/* Prevent scrolling when dialog is open */
body.dialog-open {
overflow: hidden;
}
.container { .container {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
@@ -108,22 +124,30 @@
fill: var(--text-primary); fill: var(--text-primary);
} }
#locale-toggle { #locale-select {
background: none; background: var(--bg-tertiary);
border: 2px solid var(--text-secondary); border: 1px solid var(--border);
color: var(--text-primary); color: var(--text-primary);
border-radius: 6px; border-radius: 6px;
padding: 6px 12px; padding: 6px 12px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: 500;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
#locale-toggle:hover { #locale-select:hover {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); }
transform: scale(1.05);
#locale-select:focus {
outline: none;
border-color: var(--accent);
}
#locale-select option {
background: var(--bg-secondary);
color: var(--text-primary);
} }
.player-container { .player-container {
@@ -457,20 +481,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 {
@@ -478,6 +512,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);
@@ -486,9 +609,15 @@
padding: 0; padding: 0;
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
margin: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
} }
/* Ensure dialogs are hidden until explicitly opened */
dialog:not([open]) {
display: none;
}
dialog::backdrop { dialog::backdrop {
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
} }
@@ -550,13 +679,15 @@
} }
.dialog-footer button { .dialog-footer button {
padding: 0.5rem 1rem; padding: 0.625rem 1.5rem;
border-radius: 6px; border-radius: 6px;
border: none; border: none;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
min-width: 100px;
white-space: nowrap;
} }
.dialog-footer .btn-primary { .dialog-footer .btn-primary {
@@ -757,14 +888,40 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
/* Footer */
footer {
text-align: center;
padding: 2rem 1rem;
margin-top: 3rem;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.875rem;
}
footer a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s;
}
footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
footer .separator {
margin: 0 0.5rem;
color: var(--text-muted);
}
</style> </style>
</head> </head>
<body> <body class="loading-translations">
<!-- Clear Token Button --> <!-- Clear Token Button -->
<button class="clear-token-btn" onclick="clearToken()" data-i18n-title="auth.logout.title" data-i18n="auth.logout" title="Clear saved token">Logout</button> <button class="clear-token-btn" onclick="clearToken()" data-i18n-title="auth.logout.title" data-i18n="auth.logout" title="Clear saved token">Logout</button>
<!-- Auth Modal --> <!-- Auth Modal -->
<div id="auth-overlay"> <div id="auth-overlay" class="hidden">
<div class="auth-modal"> <div class="auth-modal">
<h2 data-i18n="app.title">Media Server</h2> <h2 data-i18n="app.title">Media Server</h2>
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p> <p data-i18n="auth.message">Enter your API token to connect to the media server.</p>
@@ -790,7 +947,10 @@
<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"/> <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> </svg>
</button> </button>
<button id="locale-toggle" onclick="toggleLocale()" data-i18n-title="player.locale" title="Change language">EN</button> <select id="locale-select" onchange="changeLocale()" title="Change language">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
<div class="status-indicator"> <div class="status-indicator">
<span class="status-dot" id="status-dot"></span> <span class="status-dot" id="status-dot"></span>
<span id="status-text" data-i18n="player.status.disconnected">Disconnected</span> <span id="status-text" data-i18n="player.status.disconnected">Disconnected</span>
@@ -920,7 +1080,7 @@
<!-- Add/Edit Script Dialog --> <!-- Add/Edit Script Dialog -->
<dialog id="scriptDialog"> <dialog id="scriptDialog">
<div class="dialog-header"> <div class="dialog-header">
<h3 id="dialogTitle">Add Script</h3> <h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
</div> </div>
<form id="scriptForm" onsubmit="saveScript(event)"> <form id="scriptForm" onsubmit="saveScript(event)">
<div class="dialog-body"> <div class="dialog-body">
@@ -928,39 +1088,39 @@
<input type="hidden" id="scriptIsEdit"> <input type="hidden" id="scriptIsEdit">
<label> <label>
Script Name * <span data-i18n="scripts.field.name">Script Name *</span>
<input type="text" id="scriptName" required pattern="[a-zA-Z0-9_]+" <input type="text" id="scriptName" required pattern="[a-zA-Z0-9_]+"
title="Only letters, numbers, and underscores allowed" maxlength="64"> data-i18n-title="scripts.placeholder.name" title="Only letters, numbers, and underscores allowed" maxlength="64">
</label> </label>
<label> <label>
Label <span data-i18n="scripts.field.label">Label</span>
<input type="text" id="scriptLabel" placeholder="Human-readable name"> <input type="text" id="scriptLabel" data-i18n-placeholder="scripts.placeholder.label" placeholder="Human-readable name">
</label> </label>
<label> <label>
Command * <span data-i18n="scripts.field.command">Command *</span>
<input type="text" id="scriptCommand" required placeholder="e.g., shutdown /s /t 0"> <textarea id="scriptCommand" required rows="3" data-i18n-placeholder="scripts.placeholder.command" placeholder="e.g., shutdown /s /t 0"></textarea>
</label> </label>
<label> <label>
Description <span data-i18n="scripts.field.description">Description</span>
<textarea id="scriptDescription" placeholder="What does this script do?"></textarea> <textarea id="scriptDescription" data-i18n-placeholder="scripts.placeholder.description" placeholder="What does this script do?"></textarea>
</label> </label>
<label> <label>
Icon (MDI) <span data-i18n="scripts.field.icon">Icon (MDI)</span>
<input type="text" id="scriptIcon" placeholder="e.g., mdi:power"> <input type="text" id="scriptIcon" data-i18n-placeholder="scripts.placeholder.icon" placeholder="e.g., mdi:power">
</label> </label>
<label> <label>
Timeout (seconds) <span data-i18n="scripts.field.timeout">Timeout (seconds)</span>
<input type="number" id="scriptTimeout" value="30" min="1" max="300"> <input type="number" id="scriptTimeout" value="30" min="1" max="300">
</label> </label>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeScriptDialog()">Cancel</button> <button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
<button type="submit" class="btn-primary">Save</button> <button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
</div> </div>
</form> </form>
</dialog> </dialog>
@@ -968,55 +1128,91 @@
<!-- Add/Edit Callback Dialog --> <!-- Add/Edit Callback Dialog -->
<dialog id="callbackDialog"> <dialog id="callbackDialog">
<div class="dialog-header"> <div class="dialog-header">
<h3 id="callbackDialogTitle">Add Callback</h3> <h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
</div> </div>
<form id="callbackForm" onsubmit="saveCallback(event)"> <form id="callbackForm" onsubmit="saveCallback(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="callbackIsEdit"> <input type="hidden" id="callbackIsEdit">
<label> <label>
Event * <span data-i18n="callbacks.field.event">Event *</span>
<select id="callbackName" required> <select id="callbackName" required>
<option value="">Select event...</option> <option value="" data-i18n="callbacks.placeholder.event">Select event...</option>
<option value="on_play">on_play - After play succeeds</option> <option value="on_play" data-i18n="callbacks.event.on_play">on_play - After play succeeds</option>
<option value="on_pause">on_pause - After pause succeeds</option> <option value="on_pause" data-i18n="callbacks.event.on_pause">on_pause - After pause succeeds</option>
<option value="on_stop">on_stop - After stop succeeds</option> <option value="on_stop" data-i18n="callbacks.event.on_stop">on_stop - After stop succeeds</option>
<option value="on_next">on_next - After next track succeeds</option> <option value="on_next" data-i18n="callbacks.event.on_next">on_next - After next track succeeds</option>
<option value="on_previous">on_previous - After previous track succeeds</option> <option value="on_previous" data-i18n="callbacks.event.on_previous">on_previous - After previous track succeeds</option>
<option value="on_volume">on_volume - After volume change</option> <option value="on_volume" data-i18n="callbacks.event.on_volume">on_volume - After volume change</option>
<option value="on_mute">on_mute - After mute toggle</option> <option value="on_mute" data-i18n="callbacks.event.on_mute">on_mute - After mute toggle</option>
<option value="on_seek">on_seek - After seek succeeds</option> <option value="on_seek" data-i18n="callbacks.event.on_seek">on_seek - After seek succeeds</option>
<option value="on_turn_on">on_turn_on - Callback-only action</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">on_turn_off - 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">on_toggle - Callback-only action</option> <option value="on_toggle" data-i18n="callbacks.event.on_toggle">on_toggle - Callback-only action</option>
</select> </select>
</label> </label>
<label> <label>
Command * <span data-i18n="callbacks.field.command">Command *</span>
<input type="text" id="callbackCommand" required placeholder="e.g., shutdown /s /t 0"> <textarea id="callbackCommand" required rows="3" data-i18n-placeholder="callbacks.placeholder.command" placeholder="e.g., shutdown /s /t 0"></textarea>
</label> </label>
<label> <label>
Timeout (seconds) <span data-i18n="callbacks.field.timeout">Timeout (seconds)</span>
<input type="number" id="callbackTimeout" value="30" min="1" max="300"> <input type="number" id="callbackTimeout" value="30" min="1" max="300">
</label> </label>
<label> <label>
Working Directory <span data-i18n="callbacks.field.workdir">Working Directory</span>
<input type="text" id="callbackWorkingDir" placeholder="Optional"> <input type="text" id="callbackWorkingDir" data-i18n-placeholder="callbacks.placeholder.workdir" placeholder="Optional">
</label> </label>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeCallbackDialog()">Cancel</button> <button type="button" class="btn-secondary" onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
<button type="submit" class="btn-primary">Save</button> <button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
</div> </div>
</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>
<!-- Footer -->
<footer>
<div>
Created by <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">Source Code</a>
</div>
</footer>
<script> <script>
// Theme management // Theme management
function initTheme() { function initTheme() {
@@ -1049,7 +1245,10 @@
// Locale management // Locale management
let currentLocale = 'en'; let currentLocale = 'en';
let translations = {}; let translations = {};
const supportedLocales = ['en', 'ru']; const supportedLocales = {
'en': 'English',
'ru': 'Русский'
};
// Minimal inline fallback for critical UI elements // Minimal inline fallback for critical UI elements
const fallbackTranslations = { const fallbackTranslations = {
@@ -1096,7 +1295,7 @@
const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru' const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
// Only return if we support it // Only return if we support it
return supportedLocales.includes(langCode) ? langCode : 'en'; return supportedLocales[langCode] ? langCode : 'en';
} }
// Initialize locale // Initialize locale
@@ -1107,7 +1306,7 @@
// Set locale // Set locale
async function setLocale(locale) { async function setLocale(locale) {
if (!supportedLocales.includes(locale)) { if (!supportedLocales[locale]) {
locale = 'en'; locale = 'en';
} }
@@ -1122,22 +1321,29 @@
// Update all text // Update all text
updateAllText(); updateAllText();
// Update locale toggle button (if visible) // Update locale select dropdown (if visible)
updateLocaleToggle(); updateLocaleSelect();
// Remove loading class and show content
document.body.classList.remove('loading-translations');
document.body.classList.add('translations-loaded');
} }
// Toggle between locales // Change locale from dropdown
async function toggleLocale() { function changeLocale() {
const newLocale = currentLocale === 'en' ? 'ru' : 'en'; const select = document.getElementById('locale-select');
await setLocale(newLocale); const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale);
setLocale(newLocale);
}
} }
// Update locale toggle button // Update locale select dropdown
function updateLocaleToggle() { function updateLocaleSelect() {
const localeButton = document.getElementById('locale-toggle'); const select = document.getElementById('locale-select');
if (localeButton) { if (select) {
localeButton.textContent = currentLocale === 'en' ? 'RU' : 'EN'; select.value = currentLocale;
localeButton.title = t('player.locale');
} }
} }
@@ -1192,6 +1398,10 @@
let scripts = []; let scripts = [];
let lastStatus = null; // Store last status for locale switching let lastStatus = null; // Store last status for locale switching
// Dialog dirty state tracking
let scriptFormDirty = false;
let callbackFormDirty = false;
// Position interpolation // Position interpolation
let lastPositionUpdate = 0; let lastPositionUpdate = 0;
let lastPositionValue = 0; let lastPositionValue = 0;
@@ -1247,6 +1457,42 @@
authenticate(); 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();
}
});
}); });
function showAuthForm(errorMessage = '') { function showAuthForm(errorMessage = '') {
@@ -1676,12 +1922,21 @@
<tr> <tr>
<td><code>${script.name}</code></td> <td><code>${script.name}</code></td>
<td>${script.label || script.name}</td> <td>${script.label || script.name}</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" <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> 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('');
@@ -1707,8 +1962,12 @@
document.getElementById('scriptOriginalName').value = ''; document.getElementById('scriptOriginalName').value = '';
document.getElementById('scriptIsEdit').value = 'false'; document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false; document.getElementById('scriptName').disabled = false;
title.textContent = 'Add Script'; title.textContent = t('scripts.dialog.add');
// Reset dirty state
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal(); dialog.showModal();
} }
@@ -1746,7 +2005,12 @@
document.getElementById('scriptIcon').value = script.icon || ''; document.getElementById('scriptIcon').value = script.icon || '';
document.getElementById('scriptTimeout').value = script.timeout || 30; document.getElementById('scriptTimeout').value = script.timeout || 30;
title.textContent = 'Edit Script'; title.textContent = t('scripts.dialog.edit');
// Reset dirty state
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal(); dialog.showModal();
} catch (error) { } catch (error) {
console.error('Error loading script for edit:', error); console.error('Error loading script for edit:', error);
@@ -1755,8 +2019,17 @@
} }
function closeScriptDialog() { function closeScriptDialog() {
// Check if form has unsaved changes
if (scriptFormDirty) {
if (!confirm(t('scripts.confirm.unsaved'))) {
return; // User cancelled, don't close
}
}
const dialog = document.getElementById('scriptDialog'); const dialog = document.getElementById('scriptDialog');
scriptFormDirty = false; // Reset dirty state
dialog.close(); dialog.close();
document.body.classList.remove('dialog-open');
} }
async function saveScript(event) { async function saveScript(event) {
@@ -1797,6 +2070,7 @@
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
scriptFormDirty = false; // Reset dirty state before closing
closeScriptDialog(); closeScriptDialog();
// Don't reload manually - WebSocket will trigger it // Don't reload manually - WebSocket will trigger it
} else { } else {
@@ -1862,12 +2136,21 @@
tbody.innerHTML = callbacksList.map(callback => ` tbody.innerHTML = callbacksList.map(callback => `
<tr> <tr>
<td><code>${callback.name}</code></td> <td><code>${callback.name}</code></td>
<td style="max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" <td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
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('');
@@ -1886,8 +2169,12 @@
form.reset(); form.reset();
document.getElementById('callbackIsEdit').value = 'false'; document.getElementById('callbackIsEdit').value = 'false';
document.getElementById('callbackName').disabled = false; document.getElementById('callbackName').disabled = false;
title.textContent = 'Add Callback'; title.textContent = t('callbacks.dialog.add');
// Reset dirty state
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal(); dialog.showModal();
} }
@@ -1922,7 +2209,12 @@
document.getElementById('callbackTimeout').value = callback.timeout; document.getElementById('callbackTimeout').value = callback.timeout;
document.getElementById('callbackWorkingDir').value = callback.working_dir || ''; document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
title.textContent = 'Edit Callback'; title.textContent = t('callbacks.dialog.edit');
// Reset dirty state
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal(); dialog.showModal();
} catch (error) { } catch (error) {
console.error('Error loading callback for edit:', error); console.error('Error loading callback for edit:', error);
@@ -1931,8 +2223,17 @@
} }
function closeCallbackDialog() { function closeCallbackDialog() {
// Check if form has unsaved changes
if (callbackFormDirty) {
if (!confirm(t('callbacks.confirm.unsaved'))) {
return; // User cancelled, don't close
}
}
const dialog = document.getElementById('callbackDialog'); const dialog = document.getElementById('callbackDialog');
callbackFormDirty = false; // Reset dirty state
dialog.close(); dialog.close();
document.body.classList.remove('dialog-open');
} }
async function saveCallback(event) { async function saveCallback(event) {
@@ -1969,6 +2270,7 @@
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
callbackFormDirty = false; // Reset dirty state before closing
closeCallbackDialog(); closeCallbackDialog();
loadCallbacksTable(); loadCallbacksTable();
} else { } else {
@@ -2008,6 +2310,179 @@
showToast('Error deleting callback', 'error'); showToast('Error deleting callback', 'error');
} }
} }
// Execution Result Dialog Functions
function closeExecutionDialog() {
const dialog = document.getElementById('executionDialog');
dialog.close();
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');
// 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';
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');
// 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';
document.body.classList.add('dialog-open');
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>

View File

@@ -64,6 +64,7 @@
"scripts.msg.load_failed": "Failed to load script details", "scripts.msg.load_failed": "Failed to load script details",
"scripts.msg.list_failed": "Failed to load scripts", "scripts.msg.list_failed": "Failed to load scripts",
"scripts.confirm.delete": "Are you sure you want to delete the script \"{name}\"?", "scripts.confirm.delete": "Are you sure you want to delete the script \"{name}\"?",
"scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"callbacks.management": "Callback Management", "callbacks.management": "Callback Management",
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)", "callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)",
"callbacks.add": "Add", "callbacks.add": "Add",
@@ -105,5 +106,6 @@
"callbacks.msg.not_found": "Callback not found", "callbacks.msg.not_found": "Callback not found",
"callbacks.msg.load_failed": "Failed to load callback details", "callbacks.msg.load_failed": "Failed to load callback details",
"callbacks.msg.list_failed": "Failed to load callbacks", "callbacks.msg.list_failed": "Failed to load callbacks",
"callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?" "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?"
} }

View File

@@ -64,6 +64,7 @@
"scripts.msg.load_failed": "Не удалось загрузить данные скрипта", "scripts.msg.load_failed": "Не удалось загрузить данные скрипта",
"scripts.msg.list_failed": "Не удалось загрузить скрипты", "scripts.msg.list_failed": "Не удалось загрузить скрипты",
"scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?", "scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?",
"scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"callbacks.management": "Управление Обратными Вызовами", "callbacks.management": "Управление Обратными Вызовами",
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)", "callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
"callbacks.add": "Добавить", "callbacks.add": "Добавить",
@@ -105,5 +106,6 @@
"callbacks.msg.not_found": "Обратный вызов не найден", "callbacks.msg.not_found": "Обратный вызов не найден",
"callbacks.msg.load_failed": "Не удалось загрузить данные обратного вызова", "callbacks.msg.load_failed": "Не удалось загрузить данные обратного вызова",
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы", "callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?" "callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?"
} }

View File

@@ -0,0 +1,24 @@
@echo off
REM Media Server Restart Script
REM This script restarts the media server
echo Restarting Media Server...
echo.
REM Stop the server first
echo [1/2] Stopping server...
call "%~dp0\stop-server.bat"
REM Wait a moment
timeout /t 2 /nobreak >nul
REM Change to parent directory (media-server root)
cd /d "%~dp0\.."
REM Start the server
echo.
echo [2/2] Starting server...
python -m media_server.main
REM If the server exits, pause to show any error messages
pause

View 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
View 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
View 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