bcc6d40ed7
Lint & Test / test (push) Successful in 20s
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
133 lines
4.1 KiB
Python
133 lines
4.1 KiB
Python
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..auth import verify_token
|
|
from ..services.display_service import (
|
|
list_monitors,
|
|
set_brightness,
|
|
set_color_preset,
|
|
set_contrast,
|
|
set_input_source,
|
|
set_picture_mode,
|
|
set_power,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/display", tags=["display"])
|
|
|
|
|
|
class BrightnessRequest(BaseModel):
|
|
brightness: int = Field(ge=0, le=100)
|
|
|
|
|
|
class PowerRequest(BaseModel):
|
|
on: bool
|
|
|
|
|
|
class ContrastRequest(BaseModel):
|
|
contrast: int = Field(ge=0, le=100)
|
|
|
|
|
|
class InputSourceRequest(BaseModel):
|
|
source: str
|
|
|
|
|
|
class ColorPresetRequest(BaseModel):
|
|
preset: str
|
|
|
|
|
|
class PictureModeRequest(BaseModel):
|
|
code: int = Field(ge=0, le=255)
|
|
|
|
|
|
# DDC/CI hardware writes open a per-monitor handle and can take seconds —
|
|
# every public endpoint dispatches into a worker thread so the event loop
|
|
# stays responsive.
|
|
|
|
|
|
@router.get("/monitors")
|
|
async def get_monitors(
|
|
refresh: bool = False,
|
|
rediscover: bool = False,
|
|
_: str = Depends(verify_token),
|
|
) -> list[dict]:
|
|
"""List all connected monitors with their reported DDC/CI capabilities."""
|
|
monitors = await asyncio.to_thread(
|
|
list_monitors, force_refresh=refresh, rediscover=rediscover
|
|
)
|
|
logger.debug("Found %d monitors", len(monitors))
|
|
return [m.to_dict() for m in monitors]
|
|
|
|
|
|
@router.post("/brightness/{monitor_id}")
|
|
async def set_monitor_brightness(
|
|
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
|
|
) -> dict:
|
|
"""Set brightness for a specific monitor."""
|
|
success = await asyncio.to_thread(set_brightness, monitor_id, request.brightness)
|
|
if success:
|
|
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
|
|
return {"success": success}
|
|
|
|
|
|
@router.post("/power/{monitor_id}")
|
|
async def set_monitor_power(
|
|
monitor_id: int, request: PowerRequest, _: str = Depends(verify_token)
|
|
) -> dict:
|
|
"""Turn a monitor on or off."""
|
|
action = "on" if request.on else "off"
|
|
success = await asyncio.to_thread(set_power, monitor_id, request.on)
|
|
if success:
|
|
logger.info("Set monitor %d power %s", monitor_id, action)
|
|
return {"success": success}
|
|
|
|
|
|
@router.post("/contrast/{monitor_id}")
|
|
async def set_monitor_contrast(
|
|
monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token)
|
|
) -> dict:
|
|
"""Set DDC/CI contrast for a specific monitor."""
|
|
success = await asyncio.to_thread(set_contrast, monitor_id, request.contrast)
|
|
if success:
|
|
logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast)
|
|
return {"success": success}
|
|
|
|
|
|
@router.post("/input_source/{monitor_id}")
|
|
async def set_monitor_input_source(
|
|
monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token)
|
|
) -> dict:
|
|
"""Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1)."""
|
|
success = await asyncio.to_thread(set_input_source, monitor_id, request.source)
|
|
if success:
|
|
logger.info("Set monitor %d input source to %s", monitor_id, request.source)
|
|
return {"success": success}
|
|
|
|
|
|
@router.post("/color_preset/{monitor_id}")
|
|
async def set_monitor_color_preset(
|
|
monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token)
|
|
) -> dict:
|
|
"""Apply a DDC/CI color preset (color temperature) to the monitor."""
|
|
success = await asyncio.to_thread(set_color_preset, monitor_id, request.preset)
|
|
if success:
|
|
logger.info("Set monitor %d color preset to %s", monitor_id, request.preset)
|
|
return {"success": success}
|
|
|
|
|
|
@router.post("/picture_mode/{monitor_id}")
|
|
async def set_monitor_picture_mode(
|
|
monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token)
|
|
) -> dict:
|
|
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
|
success = await asyncio.to_thread(set_picture_mode, monitor_id, request.code)
|
|
if success:
|
|
logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code)
|
|
return {"success": success}
|