Files
media-player-server/media_server/static/js/websocket.js
T
alexei.dolgolyov bcc6d40ed7
Lint & Test / test (push) Successful in 20s
fix: comprehensive security, bug, performance, and UI/UX audit
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
  and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
  paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
  thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
  X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
  kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
  start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
  delegated data-action handler; remote MDI SVGs parsed and sanitized
  (strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent

Bugs
- WebSocket reconnect: close previous socket before opening new, clear
  ping interval per-socket, clear reconnectTimeout up-front, retry on
  online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
  background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
  spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
  checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip

Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
  asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
  threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
  — cache grew unbounded)
- Progress drag listeners attached only while dragging

Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
  _upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
  clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
  pip install -e . users see the source-of-truth version, not the stale
  package-metadata version baked in at install time

UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
  copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
  chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
  callbacks.empty in both en + ru
2026-05-16 13:22:46 +03:00

245 lines
8.1 KiB
JavaScript

// ============================================================
// WebSocket: Connection, reconnection, authentication
// ============================================================
import {
dom, t, setWs, getWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired, showUpdateBanner,
} from './core.js';
import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
import { loadCallbacksTable } from './callbacks.js';
import { loadHeaderLinks, loadLinksTable } from './links.js';
let reconnectTimeout = null;
let wsReconnectAttempts = 0;
// Track the ping interval against the socket that owns it so we never leak
// a timer if connectWebSocket() is called while a previous socket is still
// alive. The pair is wiped on close to avoid double-clear races.
let activeSocket = null;
let activePingInterval = null;
function clearReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
}
function clearPing() {
if (activePingInterval) {
clearInterval(activePingInterval);
activePingInterval = null;
}
}
export 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');
}
export 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);
}
export function clearToken() {
localStorage.removeItem('media_server_token');
const current = getWs();
if (current) {
try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
}
showAuthForm(t('auth.cleared'));
}
export function connectWebSocket(token) {
// Always cancel a pending reconnect first — otherwise a user-triggered
// reconnect can race a scheduled one and create two live sockets.
clearReconnect();
clearPing();
// Close any previous socket cleanly before opening a new one.
const previous = activeSocket;
activeSocket = null;
if (previous && previous.readyState <= WebSocket.OPEN) {
try { previous.close(1000, 'reconnecting'); } catch { /* ignore */ }
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl);
activeSocket = newWs;
setWs(newWs);
newWs.onopen = () => {
console.log('WebSocket connected');
wsReconnectAttempts = 0;
updateConnectionStatus(true);
hideConnectionBanner();
hideAuthForm();
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadHeaderLinks();
loadAudioDevices();
};
newWs.onmessage = (event) => {
let msg;
try {
msg = JSON.parse(event.data);
} catch (err) {
console.warn('Ignoring malformed WebSocket frame:', err);
return;
}
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 === 'update_available') {
showUpdateBanner(msg.data);
} else if (msg.type === 'audio_data') {
setFrequencyData(msg.data);
} else if (msg.type === 'error') {
console.error('WebSocket error:', msg.message);
}
};
newWs.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
};
newWs.onclose = (event) => {
console.log('WebSocket closed:', event.code);
updateConnectionStatus(false);
stopPositionInterpolation();
// Drop this socket's ping interval. Guard so we don't kill a newer
// socket's interval if reconnect already started.
if (activeSocket === newWs) {
clearPing();
activeSocket = null;
}
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);
}
clearReconnect();
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) {
connectWebSocket(savedToken || '');
}
}, delay);
} else {
showConnectionBanner(t('connection.lost'), true);
}
}
};
clearPing();
activePingInterval = setInterval(() => {
if (newWs.readyState === WebSocket.OPEN) {
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
}
}, WS_PING_INTERVAL_MS);
}
export 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');
}
export function manualReconnect() {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) {
wsReconnectAttempts = 0;
hideConnectionBanner();
connectWebSocket(savedToken || '');
}
}
// When the browser regains connectivity or the tab becomes visible again,
// drop the backoff and reconnect immediately rather than waiting out the
// current timer.
function reconnectIfNeeded() {
const current = activeSocket;
if (current && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) {
return;
}
const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) {
wsReconnectAttempts = 0;
connectWebSocket(savedToken || '');
}
}
window.addEventListener('online', reconnectIfNeeded);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) reconnectIfNeeded();
});