feat: make authentication optional — no tokens = no auth
Lint & Test / test (push) Successful in 10s

When no api_tokens are configured (the new default), all endpoints
are accessible without authentication. The frontend detects this
via /api/health's auth_required field and skips the login form.

- Backend: auth.py skips verification when api_tokens is empty
- Frontend: shared getAuthHeaders()/hasCredentials() helpers replace
  scattered token logic across all JS modules
- Health endpoint exposes auth_required for frontend discovery
- config.example.yaml ships with tokens commented out
- CLI --show-token and startup log reflect disabled state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 13:59:55 +03:00
parent f80f6e9299
commit 4d1bb78c83
14 changed files with 175 additions and 190 deletions
+6 -5
View File
@@ -1,13 +1,14 @@
# Media Server Configuration # Media Server Configuration
# Copy this file to config.yaml and customize as needed. # Copy this file to config.yaml and customize as needed.
# A secure token will be auto-generated on first run if not specified. # By default, authentication is DISABLED (no tokens = open access).
# To enable auth, uncomment and configure the api_tokens section below.
# API Tokens - Multiple tokens with friendly labels # API Tokens - Multiple tokens with friendly labels
# This allows you to identify which client is making requests in the logs # This allows you to identify which client is making requests in the logs
api_tokens: # api_tokens:
home_assistant: "your-home-assistant-token-here" # home_assistant: "your-home-assistant-token-here"
mobile: "your-mobile-app-token-here" # mobile: "your-mobile-app-token-here"
web_ui: "your-web-ui-token-here" # web_ui: "your-web-ui-token-here"
# Server settings # Server settings
host: "0.0.0.0" host: "0.0.0.0"
+20 -2
View File
@@ -15,6 +15,11 @@ security = HTTPBearer(auto_error=False)
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown") token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
def auth_enabled() -> bool:
"""Check if authentication is enabled (i.e. at least one token is configured)."""
return bool(settings.api_tokens)
def get_token_label(token: str) -> Optional[str]: def get_token_label(token: str) -> Optional[str]:
"""Get the label for a token. Returns None if token is invalid. """Get the label for a token. Returns None if token is invalid.
@@ -36,14 +41,19 @@ async def verify_token(
) -> str: ) -> str:
"""Verify the API token from the Authorization header. """Verify the API token from the Authorization header.
When no tokens are configured, authentication is skipped entirely.
Reuses the label from middleware context when already validated. Reuses the label from middleware context when already validated.
Returns: Returns:
The token label The token label (or "anonymous" when auth is disabled)
Raises: Raises:
HTTPException: If the token is missing or invalid HTTPException: If the token is missing or invalid (only when auth enabled)
""" """
if not auth_enabled():
token_label_var.set("anonymous")
return "anonymous"
# Reuse label already set by middleware to avoid redundant O(n) scan # Reuse label already set by middleware to avoid redundant O(n) scan
existing = token_label_var.get("unknown") existing = token_label_var.get("unknown")
if existing != "unknown": if existing != "unknown":
@@ -80,6 +90,10 @@ class TokenAuth:
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str | None: ) -> str | None:
"""Verify the token and return the label or raise an exception.""" """Verify the token and return the label or raise an exception."""
if not auth_enabled():
token_label_var.set("anonymous")
return "anonymous"
if credentials is None: if credentials is None:
if self.auto_error: if self.auto_error:
raise HTTPException( raise HTTPException(
@@ -122,6 +136,10 @@ async def verify_token_or_query(
Raises: Raises:
HTTPException: If the token is missing or invalid HTTPException: If the token is missing or invalid
""" """
if not auth_enabled():
token_label_var.set("anonymous")
return "anonymous"
# Reuse label already set by middleware # Reuse label already set by middleware
existing = token_label_var.get("unknown") existing = token_label_var.get("unknown")
if existing != "unknown": if existing != "unknown":
+6 -7
View File
@@ -1,7 +1,6 @@
"""Configuration management for the media server.""" """Configuration management for the media server."""
import os import os
import secrets
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -62,10 +61,10 @@ class Settings(BaseSettings):
host: str = Field(default="0.0.0.0", description="Server bind address") host: str = Field(default="0.0.0.0", description="Server bind address")
port: int = Field(default=8765, description="Server port") port: int = Field(default=8765, description="Server port")
# Authentication # Authentication (empty = auth disabled, anyone can access the API)
api_tokens: dict[str, str] = Field( api_tokens: dict[str, str] = Field(
default_factory=lambda: {"default": secrets.token_urlsafe(32)}, default_factory=dict,
description="Named API tokens for access control (label: token pairs)", description="Named API tokens for access control (label: token pairs). Empty = no auth.",
) )
# Media controller settings # Media controller settings
@@ -188,9 +187,9 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
config = { config = {
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8765, "port": 8765,
"api_tokens": { # "api_tokens": {
"default": secrets.token_urlsafe(32), # "default": "your-secret-token-here",
}, # },
"poll_interval": 1.0, "poll_interval": 1.0,
"log_level": "INFO", "log_level": "INFO",
# Audio device to control (use GET /api/audio/devices to list available devices) # Audio device to control (use GET /api/audio/devices to list available devices)
+32 -22
View File
@@ -59,9 +59,12 @@ async def lifespan(app: FastAPI):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Media Server starting on {settings.host}:{settings.port}") logger.info(f"Media Server starting on {settings.host}:{settings.port}")
# Log all configured tokens # Log authentication status
for label, token in settings.api_tokens.items(): if settings.api_tokens:
logger.info(f"API Token [{label}]: {token[:8]}...") for label, token in settings.api_tokens.items():
logger.info(f"API Token [{label}]: {token[:8]}...")
else:
logger.warning("No API tokens configured — authentication is DISABLED")
# Start WebSocket status monitor # Start WebSocket status monitor
controller = get_media_controller() controller = get_media_controller()
@@ -129,24 +132,28 @@ def create_app() -> FastAPI:
@app.middleware("http") @app.middleware("http")
async def token_logging_middleware(request: Request, call_next): async def token_logging_middleware(request: Request, call_next):
"""Extract token label and set in context for logging.""" """Extract token label and set in context for logging."""
token_label = "unknown" if not settings.api_tokens:
token_label_var.set("anonymous")
else:
token_label = "unknown"
# Try Authorization header # Try Authorization header
auth_header = request.headers.get("authorization", "") auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token = auth_header[7:] token = auth_header[7:]
label = get_token_label(token) label = get_token_label(token)
if label: if label:
token_label = label token_label = label
# Try query parameter (for artwork endpoint) # Try query parameter (for artwork endpoint)
elif "token" in request.query_params: elif "token" in request.query_params:
token = request.query_params["token"] token = request.query_params["token"]
label = get_token_label(token) label = get_token_label(token)
if label: if label:
token_label = label token_label = label
token_label_var.set(token_label)
token_label_var.set(token_label)
response = await call_next(request) response = await call_next(request)
return response return response
@@ -215,14 +222,17 @@ def main():
if args.generate_config: if args.generate_config:
config_path = generate_default_config() config_path = generate_default_config()
print(f"Configuration file generated at: {config_path}") print(f"Configuration file generated at: {config_path}")
print("API Token has been saved to the config file.") print("Authentication is disabled by default. Add api_tokens to enable it.")
return return
if args.show_token: if args.show_token:
print(f"Config directory: {get_config_dir()}") print(f"Config directory: {get_config_dir()}")
print("\nAPI Tokens:") if settings.api_tokens:
for label, token in settings.api_tokens.items(): print("\nAPI Tokens:")
print(f" {label:20} {token}") for label, token in settings.api_tokens.items():
print(f" {label:20} {token}")
else:
print("\nAuthentication is DISABLED (no tokens configured)")
return return
uvicorn.run( uvicorn.run(
+3
View File
@@ -5,6 +5,8 @@ from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from ..auth import auth_enabled
router = APIRouter(prefix="/api", tags=["health"]) router = APIRouter(prefix="/api", tags=["health"])
@@ -19,4 +21,5 @@ async def health_check() -> dict[str, Any]:
"status": "healthy", "status": "healthy",
"platform": platform.system(), "platform": platform.system(),
"version": "1.0.0", "version": "1.0.0",
"auth_required": auth_enabled(),
} }
+9 -8
View File
@@ -334,15 +334,16 @@ async def websocket_endpoint(
- {"type": "get_status"} - Request current status - {"type": "get_status"} - Request current status
""" """
# Verify token # Verify token
from ..auth import get_token_label, token_label_var from ..auth import auth_enabled, get_token_label, token_label_var
label = get_token_label(token) if token else None if auth_enabled():
if label is None: label = get_token_label(token) if token else None
await websocket.close(code=4001, reason="Invalid authentication token") if label is None:
return await websocket.close(code=4001, reason="Invalid authentication token")
return
# Set label in context for logging token_label_var.set(label)
token_label_var.set(label) else:
token_label_var.set("anonymous")
await ws_manager.connect(websocket) await ws_manager.connect(websocket)
+19 -1
View File
@@ -13,6 +13,7 @@ import {
togglePlayPause, nextTrack, previousTrack, toggleMute, togglePlayPause, nextTrack, previousTrack, toggleMute,
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS, VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
changeLocale, t, changeLocale, t,
setAuthRequired,
} from './core.js'; } from './core.js';
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI) // Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
@@ -160,8 +161,25 @@ window.addEventListener('DOMContentLoaded', async () => {
// Load version from health endpoint // Load version from health endpoint
fetchVersion(); fetchVersion();
// Check if authentication is required
let authReq = true;
try {
const healthResp = await fetch('/api/health');
const healthData = await healthResp.json();
authReq = healthData.auth_required !== false;
} catch { /* assume auth required on error */ }
setAuthRequired(authReq);
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
if (token) { if (!authReq) {
// No auth required — connect directly without token
connectWebSocket('');
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadAudioDevices();
} else if (token) {
connectWebSocket(token); connectWebSocket(token);
loadScripts(); loadScripts();
loadScriptsTable(); loadScriptsTable();
+13 -36
View File
@@ -5,6 +5,7 @@
import { import {
t, showToast, escapeHtml, closeDialog, t, showToast, escapeHtml, closeDialog,
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml, SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
getAuthHeaders, hasCredentials,
} from './core.js'; } from './core.js';
// Browser state // Browser state
@@ -24,14 +25,10 @@ const THUMBNAIL_CACHE_MAX = 200;
// Load media folders on page load // Load media folders on page load
export async function loadMediaFolders() { export async function loadMediaFolders() {
try { try {
const token = localStorage.getItem('media_server_token'); if (!hasCredentials()) return;
if (!token) {
console.error('No API token found');
return;
}
const response = await fetch('/api/browser/folders', { const response = await fetch('/api/browser/folders', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) throw new Error('Failed to load folders'); if (!response.ok) throw new Error('Failed to load folders');
@@ -119,11 +116,7 @@ async function browsePath(folderId, path, offset = 0, nocache = false) {
showBrowserSearch(false); showBrowserSearch(false);
try { try {
const token = localStorage.getItem('media_server_token'); if (!hasCredentials()) return;
if (!token) {
console.error('No API token found');
return;
}
// Show loading spinner // Show loading spinner
const container = document.getElementById('browserGrid'); const container = document.getElementById('browserGrid');
@@ -135,7 +128,7 @@ async function browsePath(folderId, path, offset = 0, nocache = false) {
if (nocache) url += '&nocache=true'; if (nocache) url += '&nocache=true';
const response = await fetch( const response = await fetch(
url, url,
{ headers: { 'Authorization': `Bearer ${token}` } } { headers: getAuthHeaders() }
); );
if (!response.ok) { if (!response.ok) {
@@ -487,11 +480,7 @@ function formatBitrate(bps) {
async function loadThumbnail(imgElement, fileName) { async function loadThumbnail(imgElement, fileName) {
try { try {
const token = localStorage.getItem('media_server_token'); if (!hasCredentials()) return;
if (!token) {
console.error('No API token found');
return;
}
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
@@ -510,7 +499,7 @@ async function loadThumbnail(imgElement, fileName) {
const response = await fetch( const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`, `/api/browser/thumbnail?path=${encodedPath}&size=medium`,
{ headers: { 'Authorization': `Bearer ${token}` } } { headers: getAuthHeaders() }
); );
if (response.status === 200) { if (response.status === 200) {
@@ -576,20 +565,13 @@ async function playMediaFile(fileName) {
if (playInProgress) return; if (playInProgress) return;
playInProgress = true; playInProgress = true;
try { try {
const token = localStorage.getItem('media_server_token'); if (!hasCredentials()) return;
if (!token) {
console.error('No API token found');
return;
}
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
const response = await fetch('/api/browser/play', { const response = await fetch('/api/browser/play', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: absolutePath }) body: JSON.stringify({ path: absolutePath })
}); });
@@ -610,15 +592,11 @@ export async function playAllFolder() {
const btn = document.getElementById('playAllBtn'); const btn = document.getElementById('playAllBtn');
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
try { try {
const token = localStorage.getItem('media_server_token'); if (!hasCredentials() || !currentFolderId) return;
if (!token || !currentFolderId) return;
const response = await fetch('/api/browser/play-folder', { const response = await fetch('/api/browser/play-folder', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ folder_id: currentFolderId, path: currentPath }) body: JSON.stringify({ folder_id: currentFolderId, path: currentPath })
}); });
@@ -640,8 +618,7 @@ export async function playAllFolder() {
export async function downloadFile(fileName, event) { export async function downloadFile(fileName, event) {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
const token = localStorage.getItem('media_server_token'); if (!hasCredentials()) return;
if (!token) return;
const fullPath = currentPath === '/' const fullPath = currentPath === '/'
? '/' + fileName ? '/' + fileName
@@ -651,7 +628,7 @@ export async function downloadFile(fileName, event) {
try { try {
const response = await fetch( const response = await fetch(
`/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`, `/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`,
{ headers: { 'Authorization': `Bearer ${token}` } } { headers: getAuthHeaders() }
); );
if (!response.ok) throw new Error('Download failed'); if (!response.ok) throw new Error('Download failed');
+5 -15
View File
@@ -2,7 +2,7 @@
// Callbacks: CRUD management // Callbacks: CRUD management
// ============================================================ // ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm } from './core.js'; import { t, showToast, escapeHtml, closeDialog, showConfirm, getAuthHeaders, hasCredentials } from './core.js';
export let callbackFormDirty = false; export let callbackFormDirty = false;
export function setCallbackFormDirty(value) { callbackFormDirty = value; } export function setCallbackFormDirty(value) { callbackFormDirty = value; }
@@ -16,12 +16,11 @@ export async function loadCallbacksTable() {
} }
async function _loadCallbacksTableImpl() { async function _loadCallbacksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('callbacksTableBody'); const tbody = document.getElementById('callbacksTableBody');
try { try {
const response = await fetch('/api/callbacks/list', { const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -79,13 +78,12 @@ export function showAddCallbackDialog() {
} }
export async function showEditCallbackDialog(callbackName) { export async function showEditCallbackDialog(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('callbackDialog'); const dialog = document.getElementById('callbackDialog');
const title = document.getElementById('callbackDialogTitle'); const title = document.getElementById('callbackDialogTitle');
try { try {
const response = await fetch('/api/callbacks/list', { const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -137,7 +135,6 @@ export async function saveCallback(event) {
const submitBtn = event.target.querySelector('button[type="submit"]'); const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true; if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('callbackIsEdit').value === 'true'; const isEdit = document.getElementById('callbackIsEdit').value === 'true';
const callbackName = document.getElementById('callbackName').value; const callbackName = document.getElementById('callbackName').value;
@@ -157,10 +154,7 @@ export async function saveCallback(event) {
try { try {
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method, method,
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
@@ -187,14 +181,10 @@ export async function deleteCallbackConfirm(callbackName) {
return; return;
} }
const token = localStorage.getItem('media_server_token');
try { try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, { const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: getAuthHeaders()
'Authorization': `Bearer ${token}`
}
}); });
const result = await response.json(); const result = await response.json();
+27 -8
View File
@@ -300,8 +300,7 @@ function updateAllText() {
document.getElementById('sourceIcon').innerHTML = initSrc?.icon || ''; document.getElementById('sourceIcon').innerHTML = initSrc?.icon || '';
} }
const token = localStorage.getItem('media_server_token'); if (hasCredentials()) {
if (token) {
if (_loadScriptsTable) _loadScriptsTable(); if (_loadScriptsTable) _loadScriptsTable();
if (_loadCallbacksTable) _loadCallbacksTable(); if (_loadCallbacksTable) _loadCallbacksTable();
if (_loadLinksTable) _loadLinksTable(); if (_loadLinksTable) _loadLinksTable();
@@ -396,19 +395,39 @@ export function showConfirm(message) {
}); });
} }
// ============================================================
// Auth Helpers
// ============================================================
// Set to false when server reports auth_required: false
export let authRequired = true;
export function setAuthRequired(value) { authRequired = value; }
/**
* Build Authorization headers for API requests.
* Returns empty object when auth is disabled or no token is stored.
*/
export function getAuthHeaders() {
const token = localStorage.getItem('media_server_token');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
/**
* Check if we have sufficient credentials to call the API.
* True when auth is disabled OR a token is stored.
*/
export function hasCredentials() {
return !authRequired || !!localStorage.getItem('media_server_token');
}
// ============================================================ // ============================================================
// API Commands // API Commands
// ============================================================ // ============================================================
export async function sendCommand(endpoint, body = null) { export async function sendCommand(endpoint, body = null) {
const token = localStorage.getItem('media_server_token');
const options = { const options = {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}; };
if (body) { if (body) {
+11 -31
View File
@@ -2,21 +2,20 @@
// Display Brightness & Power Control + Links Management // Display Brightness & Power Control + Links Management
// ============================================================ // ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon } from './core.js'; import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
let displayBrightnessTimers = {}; let displayBrightnessTimers = {};
const DISPLAY_THROTTLE_MS = 50; const DISPLAY_THROTTLE_MS = 50;
export async function loadDisplayMonitors() { export async function loadDisplayMonitors() {
const token = localStorage.getItem('media_server_token'); if (!hasCredentials()) return;
if (!token) return;
const container = document.getElementById('displayMonitors'); const container = document.getElementById('displayMonitors');
if (!container) return; if (!container) return;
try { try {
const response = await fetch('/api/display/monitors?refresh=true', { const response = await fetch('/api/display/monitors?refresh=true', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -108,14 +107,10 @@ export function onDisplayBrightnessChange(monitorId, value) {
} }
async function sendDisplayBrightness(monitorId, brightness) { async function sendDisplayBrightness(monitorId, brightness) {
const token = localStorage.getItem('media_server_token');
try { try {
await fetch(`/api/display/brightness/${monitorId}`, { await fetch(`/api/display/brightness/${monitorId}`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ brightness }) body: JSON.stringify({ brightness })
}); });
} catch (e) { } catch (e) {
@@ -128,14 +123,10 @@ export async function toggleDisplayPower(monitorId, monitorName) {
const isOn = btn && btn.classList.contains('on'); const isOn = btn && btn.classList.contains('on');
const newState = !isOn; const newState = !isOn;
const token = localStorage.getItem('media_server_token');
try { try {
const response = await fetch(`/api/display/power/${monitorId}`, { const response = await fetch(`/api/display/power/${monitorId}`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ on: newState }) body: JSON.stringify({ on: newState })
}); });
const data = await response.json(); const data = await response.json();
@@ -160,15 +151,14 @@ export async function toggleDisplayPower(monitorId, monitorName) {
// ============================================================ // ============================================================
export async function loadHeaderLinks() { export async function loadHeaderLinks() {
const token = localStorage.getItem('media_server_token'); if (!hasCredentials()) return;
if (!token) return;
const container = document.getElementById('headerLinks'); const container = document.getElementById('headerLinks');
if (!container) return; if (!container) return;
try { try {
const response = await fetch('/api/links/list', { const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) return; if (!response.ok) return;
@@ -210,12 +200,11 @@ export async function loadLinksTable() {
} }
async function _loadLinksTableImpl() { async function _loadLinksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('linksTableBody'); const tbody = document.getElementById('linksTableBody');
try { try {
const response = await fetch('/api/links/list', { const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -273,13 +262,12 @@ export function showAddLinkDialog() {
} }
export async function showEditLinkDialog(linkName) { export async function showEditLinkDialog(linkName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('linkDialog'); const dialog = document.getElementById('linkDialog');
const title = document.getElementById('linkDialogTitle'); const title = document.getElementById('linkDialogTitle');
try { try {
const response = await fetch('/api/links/list', { const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -342,7 +330,6 @@ export async function saveLink(event) {
const submitBtn = event.target.querySelector('button[type="submit"]'); const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true; if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('linkIsEdit').value === 'true'; const isEdit = document.getElementById('linkIsEdit').value === 'true';
const linkName = isEdit ? const linkName = isEdit ?
document.getElementById('linkOriginalName').value : document.getElementById('linkOriginalName').value :
@@ -364,10 +351,7 @@ export async function saveLink(event) {
try { try {
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method, method,
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
@@ -393,14 +377,10 @@ export async function deleteLinkConfirm(linkName) {
return; return;
} }
const token = localStorage.getItem('media_server_token');
try { try {
const response = await fetch(`/api/links/delete/${linkName}`, { const response = await fetch(`/api/links/delete/${linkName}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: getAuthHeaders()
'Authorization': `Bearer ${token}`
}
}); });
const result = await response.json(); const result = await response.json();
+6 -13
View File
@@ -9,6 +9,7 @@ import {
currentPosition, setCurrentPosition, isUserAdjustingVolume, currentPosition, setCurrentPosition, isUserAdjustingVolume,
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState, lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
POSITION_INTERPOLATION_MS, seek, POSITION_INTERPOLATION_MS, seek,
getAuthHeaders, hasCredentials,
} from './core.js'; } from './core.js';
import { updateBackgroundColors } from './background.js'; import { updateBackgroundColors } from './background.js';
import { loadDisplayMonitors } from './links.js'; import { loadDisplayMonitors } from './links.js';
@@ -282,9 +283,8 @@ const VISUALIZER_SMOOTHING = 0.15;
export async function checkVisualizerAvailability() { export async function checkVisualizerAvailability() {
try { try {
const token = localStorage.getItem('media_server_token');
const resp = await fetch('/api/media/visualizer/status', { const resp = await fetch('/api/media/visualizer/status', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
@@ -428,14 +428,12 @@ export async function loadAudioDevices() {
if (!section || !select) return; if (!section || !select) return;
try { try {
const token = localStorage.getItem('media_server_token');
const [devicesResp, statusResp] = await Promise.all([ const [devicesResp, statusResp] = await Promise.all([
fetch('/api/media/visualizer/devices', { fetch('/api/media/visualizer/devices', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}), }),
fetch('/api/media/visualizer/status', { fetch('/api/media/visualizer/status', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}) })
]); ]);
@@ -495,15 +493,11 @@ export async function onAudioDeviceChanged() {
if (!select) return; if (!select) return;
const deviceName = select.value || null; const deviceName = select.value || null;
const token = localStorage.getItem('media_server_token');
try { try {
const resp = await fetch('/api/media/visualizer/device', { const resp = await fetch('/api/media/visualizer/device', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ device_name: deviceName }) body: JSON.stringify({ device_name: deviceName })
}); });
@@ -612,9 +606,8 @@ export function updateUI(status) {
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 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"; 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) { if (artworkSource) {
const token = localStorage.getItem('media_server_token');
fetch(`/api/media/artwork?_=${Date.now()}`, { fetch(`/api/media/artwork?_=${Date.now()}`, {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}) })
.then(r => r.ok ? r.blob() : null) .then(r => r.ok ? r.blob() : null)
.then(blob => { .then(blob => {
+11 -37
View File
@@ -6,19 +6,16 @@ import {
t, showToast, escapeHtml, closeDialog, showConfirm, t, showToast, escapeHtml, closeDialog, showConfirm,
resolveMdiIcons, fetchMdiIcon, resolveMdiIcons, fetchMdiIcon,
scripts, setScripts, scripts, setScripts,
getAuthHeaders, hasCredentials,
} from './core.js'; } from './core.js';
export let scriptFormDirty = false; export let scriptFormDirty = false;
export function setScriptFormDirty(value) { scriptFormDirty = value; } export function setScriptFormDirty(value) { scriptFormDirty = value; }
export async function loadScripts() { export async function loadScripts() {
const token = localStorage.getItem('media_server_token');
try { try {
const response = await fetch('/api/scripts/list', { const response = await fetch('/api/scripts/list', {
headers: { headers: getAuthHeaders()
'Authorization': `Bearer ${token}`
}
}); });
if (response.ok) { if (response.ok) {
@@ -67,10 +64,9 @@ export async function displayQuickAccess() {
}); });
try { try {
const token = localStorage.getItem('media_server_token'); if (hasCredentials()) {
if (token) {
const response = await fetch('/api/links/list', { const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (gen !== _quickAccessGen) return; if (gen !== _quickAccessGen) return;
if (response.ok) { if (response.ok) {
@@ -124,16 +120,12 @@ export async function displayQuickAccess() {
} }
async function executeScript(scriptName, buttonElement) { async function executeScript(scriptName, buttonElement) {
const token = localStorage.getItem('media_server_token');
buttonElement.classList.add('executing'); buttonElement.classList.add('executing');
try { try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, { const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] }) body: JSON.stringify({ args: [] })
}); });
@@ -165,12 +157,11 @@ export async function loadScriptsTable() {
} }
async function _loadScriptsTableImpl() { async function _loadScriptsTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('scriptsTableBody'); const tbody = document.getElementById('scriptsTableBody');
try { try {
const response = await fetch('/api/scripts/list', { const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -232,13 +223,12 @@ export function showAddScriptDialog() {
} }
export async function showEditScriptDialog(scriptName) { export async function showEditScriptDialog(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('scriptDialog'); const dialog = document.getElementById('scriptDialog');
const title = document.getElementById('dialogTitle'); const title = document.getElementById('dialogTitle');
try { try {
const response = await fetch('/api/scripts/list', { const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` } headers: getAuthHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -300,7 +290,6 @@ export async function saveScript(event) {
const submitBtn = event.target.querySelector('button[type="submit"]'); const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true; if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('scriptIsEdit').value === 'true'; const isEdit = document.getElementById('scriptIsEdit').value === 'true';
const scriptName = isEdit ? const scriptName = isEdit ?
document.getElementById('scriptOriginalName').value : document.getElementById('scriptOriginalName').value :
@@ -324,10 +313,7 @@ export async function saveScript(event) {
try { try {
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method, method,
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
@@ -353,14 +339,10 @@ export async function deleteScriptConfirm(scriptName) {
return; return;
} }
const token = localStorage.getItem('media_server_token');
try { try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, { const response = await fetch(`/api/scripts/delete/${scriptName}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: getAuthHeaders()
'Authorization': `Bearer ${token}`
}
}); });
const result = await response.json(); const result = await response.json();
@@ -443,7 +425,6 @@ function showExecutionResult(name, result, type = 'script') {
} }
export async function executeScriptDebug(scriptName) { export async function executeScriptDebug(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog'); const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle'); const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus'); const statusDiv = document.getElementById('executionStatus');
@@ -463,10 +444,7 @@ export async function executeScriptDebug(scriptName) {
try { try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, { const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] }) body: JSON.stringify({ args: [] })
}); });
@@ -494,7 +472,6 @@ export async function executeScriptDebug(scriptName) {
} }
export async function executeCallbackDebug(callbackName) { export async function executeCallbackDebug(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog'); const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle'); const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus'); const statusDiv = document.getElementById('executionStatus');
@@ -514,10 +491,7 @@ export async function executeCallbackDebug(callbackName) {
try { try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, { const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}); });
const result = await response.json(); const result = await response.json();
+7 -5
View File
@@ -6,6 +6,7 @@ import {
dom, t, showToast, setWs, dom, t, showToast, setWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS, WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired,
} from './core.js'; } from './core.js';
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js'; import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js'; import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
@@ -62,7 +63,8 @@ export function connectWebSocket(token) {
} }
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`; const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl); const newWs = new WebSocket(wsUrl);
setWs(newWs); setWs(newWs);
@@ -134,8 +136,8 @@ export function connectWebSocket(token) {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
const savedToken = localStorage.getItem('media_server_token'); const savedToken = localStorage.getItem('media_server_token');
if (savedToken) { if (savedToken || !authRequired) {
connectWebSocket(savedToken); connectWebSocket(savedToken || '');
} }
}, delay); }, delay);
} else { } else {
@@ -175,9 +177,9 @@ function hideConnectionBanner() {
export function manualReconnect() { export function manualReconnect() {
const savedToken = localStorage.getItem('media_server_token'); const savedToken = localStorage.getItem('media_server_token');
if (savedToken) { if (savedToken || !authRequired) {
wsReconnectAttempts = 0; wsReconnectAttempts = 0;
hideConnectionBanner(); hideConnectionBanner();
connectWebSocket(savedToken); connectWebSocket(savedToken || '');
} }
} }