Compare commits

...

27 Commits

Author SHA1 Message Date
alexei.dolgolyov 2ddbb93537 ci: seed config before linux-smoke launch so the server actually serves
Lint & Test / test (push) Successful in 12s
Lint & Test / linux-smoke (push) Successful in 20s
With the PyGObject girepository-2.0 fix in place, the linux-smoke step
ran its server-boot assertion for the first time and failed: on a fresh
runner the first-run bootstrap writes a default config and calls
sys.exit(0) ("First run: generated default config ... then restart")
instead of serving, so /api/health never came up and the 15s wait
timed out.

That exit-on-first-run is deliberate product behavior (never silently
start in insecure no-auth mode), so adjust the test rather than the app:
invoke the server once to seed the config (it exits 0 before binding the
port), then launch it for real. /api/health requires no auth, so the
auto-generated token is irrelevant to the check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:34:00 +03:00
alexei.dolgolyov b7e50455ad ci: fix Linux build — install libgirepository-2.0-dev for PyGObject
Lint & Test / test (push) Successful in 16s
Lint & Test / linux-smoke (push) Failing after 49s
PyGObject >= 3.52 dropped the standalone gobject-introspection
girepository-1.0 dependency and now builds against girepository-2.0,
which was merged into GLib 2.80. The linux extra pins PyGObject>=3.46
with no upper bound, so pip resolves the newest release (3.56.3) and
meson aborts metadata generation with:

  Dependency 'girepository-2.0' is required but not found.

because CI only installed the old libgirepository1.0-dev.

Swap libgirepository1.0-dev -> libgirepository-2.0-dev (shipped by
GLib 2.80 on the ubuntu-latest / 24.04 runner) across all three Linux
pip-install paths so they stay in sync:

- test.yml: the failing linux-smoke job.
- release.yml: build-linux, which would otherwise ship a broken
  Linux tarball on the next tag.
- build.yml: build-linux had no system-deps step at all; added the
  matching apt install so the manual artifact build works too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:18:51 +03:00
alexei.dolgolyov 0006620eb5 ci: temporarily disable macOS build job (no runner available)
Lint & Test / test (push) Successful in 13s
Lint & Test / linux-smoke (push) Failing after 29s
The Gitea instance currently has no macOS runner attached, so the
build-macos job was failing visibly on every release even with
continue-on-error: true, and the release body advertised macOS
downloads that were never produced.

- Gate build-macos with `if: false` (job is preserved verbatim
  except for the gate, so re-enablement is a one-line delete).
- Drop the macOS rows from the Downloads table generated by the
  create-release job. Kept as commented-out lines inside the heredoc.

Both changes carry a `TODO(macos-runner)` marker — `grep` for it
to find every site that needs flipping when a macOS runner is
connected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:34:09 +03:00
alexei.dolgolyov e7a3f62a9a chore: release v0.4.0
Lint & Test / test (push) Has been skipped
Lint & Test / linux-smoke (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-windows (push) Successful in 1m9s
Release / build-linux (push) Successful in 1m10s
Release / build-macos (push) Has been cancelled
2026-05-28 17:27:37 +03:00
alexei.dolgolyov d798fedf55 feat(icon): redesign app icon as "Beacon" and ship multi-resolution ICO
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s
- Replace generic Spotify-green circle with a refined "Beacon" design:
  squircle + deep-teal diagonal gradient (#0B3D3B → #1A6B5E) + warm
  parchment play triangle (#F5F1E8) with drop shadow, top sheen, and
  ghosted echo-chevrons hinting at broadcast/stream
- Grow icon.ico from a single 16×16 frame (208 B) to a 10-frame
  multi-resolution ICO (16/20/24/32/40/48/64/96/128/256, ~37 KB) so
  Windows no longer upscales 16×16 into mush for the installer chrome,
  Start Menu, desktop shortcuts, Alt+Tab, and File Explorer tiles
- Add scripts/generate-icon.py: SVG is the source of truth; resvg-py
  rasterizes every ICO size; Pillow packs the multi-resolution ICO
- Update tray.py to pick a 64×64 frame from the new ICO and update its
  procedural fallback to the same Beacon palette so a missing ICO no
  longer regresses the tray to the old Spotify-green circle
- Add resvg-py to [dev] deps (build-time only)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:18:58 +03:00
alexei.dolgolyov ddf4a6cb29 feat: production-ready Linux & macOS support
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s
- Add `linux` (dbus-python, PyGObject, python-xlib) and `macos`
  (pyobjc) extras to pyproject.toml with sys_platform markers; move
  cross-platform screen-brightness-control + monitorcontrol to base deps.
- build-dist-linux.sh: install `.[linux]`, pkg-config pre-flight for
  dbus-1/glib-2.0, emit a systemd unit with DBUS_SESSION_BUS_ADDRESS +
  XDG_RUNTIME_DIR + ReadWritePaths for ~/.config and ~/.cache so MPRIS
  works and audit-log / thumbnail writes aren't blocked by ProtectHome.
- New build-dist-macos.sh + per-user LaunchAgent installer producing
  MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz.
- Templated media-server.service updated to match the dist layout with
  proper session-bus env vars and a writable state-dir grant.
- install_linux.sh: drop dead requirements.txt path; install via
  `pip install ".[linux]"` and pre-create the writable state dirs.
- Cross-platform album artwork: abstract MediaController.get_album_art()
  with Linux (mpris:artUrl, file:// + http(s)://) and macOS (Spotify URL)
  impls; routes/media artwork endpoint now awaits the controller.
- LinuxMediaController connects to the session bus lazily — failure no
  longer crashes lifespan startup; MPRIS calls return idle until the bus
  is reachable. Logged once at INFO with a hint about
  `loginctl enable-linger`.
- Startup preflight on Linux warns if DBUS_SESSION_BUS_ADDRESS or
  XDG_RUNTIME_DIR is unset and informs the user when Wayland disables
  the foreground probe.
- /api/media/visualizer/status now reports a per-OS unavailable_reason.
- tray._confirm guarded against ctypes.windll on non-Windows.
- config.example.yaml: per-OS commented script examples; on_turn_off
  default is now a no-op echo (used to silently fail off Windows).
- README: replace stale `pip install -r requirements.txt` instructions
  with the new extras; add systemd lingering doc + troubleshooting
  section; add macOS LaunchAgent section.
- CI: new linux-smoke job (installs `.[linux]`, boots the server under
  dbus-run-session, asserts /api/health). Release workflow gains
  apt-deps step for the Linux build and a best-effort macOS build job.
2026-05-26 12:17:30 +03:00
alexei.dolgolyov 82710c6457 chore: release v0.3.1
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 28s
Release / build-windows (push) Successful in 52s
2026-05-25 23:45:08 +03:00
alexei.dolgolyov 9b9a2b5c9f fix(ws): accept same-origin WebSocket connections in default Origin allow-list
When `cors_origins` was unset, the WS endpoint only allowed
`http://localhost:<port>` and `http://127.0.0.1:<port>` as origins, so a
browser opening the UI via the LAN IP (e.g. `http://192.168.2.100:8765`
when bound to `0.0.0.0`) had its WebSocket closed with code 4003 and
never recovered — leaving the Web UI in a permanent reconnect loop.

Also accept any `Origin` whose authority matches the request's `Host`
header (both `http://` and `https://` schemes). Same-origin is by
definition not CSWSH, so the cross-origin defence added in v0.3.0
remains intact for genuine third-party LAN pages.
2026-05-25 23:44:57 +03:00
alexei.dolgolyov b023d72165 chore: release v0.3.0
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 1m29s
Release / build-windows (push) Successful in 1m42s
2026-05-22 22:41:11 +03:00
alexei.dolgolyov d131ba461c fix: production-readiness hardening — security, perf, a11y, observability
Lint & Test / test (push) Successful in 20s
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- display_service _static_cache keyed by (edid_hash, ...) tuple with
  eviction of disappeared monitors — fixes stale capabilities on hot-plug
  swaps where the new topology has the same monitor count.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00
alexei.dolgolyov 450f9fe1ee chore: release v0.2.7
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 52s
2026-05-19 01:34:36 +03:00
alexei.dolgolyov e1c8474271 fix(csp): wire display sliders and accent picker without inline on*
Lint & Test / test (push) Successful in 10s
The display brightness/contrast sliders and the accent color picker
rendered dynamic HTML with inline oninput/onchange/onclick attributes,
which are blocked by the script-src 'self' CSP — so display settings
were silently un-clickable from the WebUI.

Replace the inline attributes with data-* markers, then attach proper
event listeners after innerHTML (delegated on the container for the
slider rows, direct for the accent dropdown).
2026-05-19 01:17:47 +03:00
alexei.dolgolyov fe82836f4d chore: release v0.2.6
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 55s
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:19:07 +03:00
alexei.dolgolyov eeab9b2a26 style: sort Xlib import in foreground_service
Resolves the ruff I001 warning introduced by 61cdce9.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:19:02 +03:00
alexei.dolgolyov 61cdce9b60 feat(foreground): track topmost process + browser page title
Lint & Test / test (push) Failing after 8s
Adds cross-platform foreground-window tracking and exposes it over REST
(/api/foreground) and the existing WebSocket feed.

- foreground_service.py: Windows probe via ctypes (HANDLE-correct argtypes
  to avoid 64-bit handle truncation); macOS via AppKit; Linux via Xlib
  (Wayland returns unavailable). TTL cache + per-platform fallback.
- browser_url_service.py: when foreground is a recognised browser, extract
  the page title from the window title (browser-name suffix stripped) and
  surface `is_browser` + `browser_page_title`. Optional UIA-based URL
  extraction behind MEDIA_SERVER_BROWSER_UIA env flag (off by default —
  Chromium browsers keep their accessibility tree dormant otherwise).
- websocket_manager: poll foreground every 1s inside the existing status
  loop, broadcast `foreground` on connect and `foreground_update` on
  change. Diff only on user-visible fields to avoid geometry spam.
- WebUI: new editorial card rendered under the monitor list on the
  Display tab — process name, window title, fullscreen/minimized/monitor
  chips, browser block when applicable, exe path, PID, started-ago,
  geometry, platform. 16px inter-section gap matches Settings cadence.
- i18n: 25 new keys added to both en.json and ru.json.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:11:59 +03:00
alexei.dolgolyov 0cf49deac0 fix(config): secure-by-default loopback bind and startup-error logging
- Default `host: 127.0.0.1` in config.example.yaml; require explicit
  api_tokens or `allow_lan_without_auth: true` before binding LAN.
- Mirror pre-uvicorn fatal errors to startup-errors.log in the config
  dir so silent boot failures via wscript/pythonw are diagnosable.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:11:08 +03:00
alexei.dolgolyov 527f3d0aa4 chore: release v0.2.5
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 44s
Release / build-windows (push) Successful in 1m9s
2026-05-16 20:16:45 +03:00
alexei.dolgolyov 982dda42ac fix(browser): align list columns via subgrid and fix icon sizing
Lint & Test / test (push) Successful in 10s
- Switch .browser-list to CSS grid + subgrid so header and rows share
  the same column track widths, eliminating misaligned columns when
  content widths differ between rows.
- Apply matching responsive column overrides at the parent grid level.
- Override root-folder SVG sizing (hardcoded 24x24 in browser.js) so it
  fills the 56px icon box instead of rendering at ~43%.
- Make compact grid icon fill its thumb wrapper so the emoji centers
  instead of being stranded in the top-left corner.
- Remove premature isConnected bail in loadThumbnail; the img element
  is intentionally detached when called from renderBrowserGrid/List.
  Post-await checks already handle navigation-away correctly.
2026-05-16 20:12:39 +03:00
alexei.dolgolyov eaeebb64cd fix(csp): replace inline on* handlers with data-on* + JS wiring
Lint & Test / test (push) Successful in 38s
The strict `script-src 'self'` CSP blocks inline onclick/onchange/oninput/
onsubmit attribute evaluation, breaking every button and form in the UI.

- Rename all 53 inline handler attributes in index.html to data-on*
- Add wireInlineHandlers() in app.js that parses each data-on* expression
  on DOMContentLoaded and attaches a proper addEventListener calling the
  matching window-global function. Supports no-arg, string/number/bool/null
  literals, and the `event` token.

CSP stays strict; no unsafe-inline or unsafe-hashes needed.
2026-05-16 18:35:51 +03:00
alexei.dolgolyov bcc6d40ed7 fix: comprehensive security, bug, performance, and UI/UX audit
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
2026-05-16 13:22:46 +03:00
alexei.dolgolyov 770bba7e60 chore: release v0.2.4
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 23s
Release / build-windows (push) Successful in 50s
2026-05-15 14:50:28 +03:00
alexei.dolgolyov d1f621f0b4 fix(displays): verify DDC/CI writes and trust capability string for picture mode
Lint & Test / test (push) Successful in 10s
DDC/CI writes are fire-and-forget at the protocol level: a successful send
does not mean the monitor honored the value. Many monitors (LG ultrawides
in particular) silently drop writes for VCP codes whose registers exist
but whose feature isn't really implemented in firmware.

- New _verify_after_set helper polls readback after every DDC/CI write and
  reports {success: false} when the monitor didn't apply the value. Wired
  into set_contrast, set_input_source, set_color_preset, set_picture_mode.
  Input source uses a longer settle window since switching can briefly
  disrupt the DDC/CI link.

- Picture mode (VCP 0xDC) now requires the capability string to declare
  supported codes under cmds[0xDC]. Without that declaration we treat the
  feature as unsupported even when reads succeed - the LG case where reads
  return a stuck value and every write is silently ignored.
2026-05-15 14:45:40 +03:00
alexei.dolgolyov 6120625fa9 chore(scripts): harden restart-server.ps1 against installer vs dev launches
Lint & Test / test (push) Successful in 13s
The previous version only killed processes named 'media-server', which
silently missed the installer-bundled process (which runs as plain
python.exe via media-server.bat). The new version:

- Kills whatever currently owns the listen port, regardless of process name
- Supports -Mode auto|dev|installer; auto-detects based on whether the
  installer launcher exists in %LOCALAPPDATA%\Media Server
- Verifies the port is listening after start
- Merges registry PATH so newly-installed dev tools are visible

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:28:14 +03:00
alexei.dolgolyov 57fdeb70fb feat(displays): expose DDC/CI contrast, input source, color preset, picture mode
Backend (routes/display.py, services/display_service.py):
- Probe DDC/CI capabilities per monitor at enumeration time
- New endpoints POST /api/display/{contrast,input_source,color_preset,picture_mode}/{id}
- Picture mode goes through raw VCP 0xDC since monitorcontrol has no
  high-level wrapper; labels follow MCCS spec with vendor-friendly fallbacks
- Each capability reports a *_supported flag so the UI can hide rows that
  the hardware does not advertise

Frontend (links.js, app.js, styles.css, locales):
- Monitor cards grow a contrast slider (same editorial copper treatment
  as brightness) and a "PICTURE TUNING" section beneath
- Picture tuning uses the IconSelect widget (matching the audio device
  selector): per-port icons (HDMI, DisplayPort, DVI, VGA, USB-C),
  thermometer for color temps, per-mode icons (movie reel, gamepad,
  ball, etc.) for picture modes
- Humanizers turn SHOUT_CASE enum names into readable labels
  (COLOR_TEMP_6500K -> "6500 K", HDMI1 -> "HDMI 1")
- 14 new i18n keys per locale (en/ru)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:28:04 +03:00
alexei.dolgolyov 0d07f7f1f4 chore: release v0.2.3
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 1m21s
2026-05-01 19:41:41 +03:00
alexei.dolgolyov 372e4eb11f fix(displays): keep primary-display star visible on long monitor names
Wrapping overflow:hidden + ellipsis on the parent flex container
clipped the favourite star whenever the monitor name was long enough
to truncate. Move the truncation rules onto a new inner span around
the name text only, and add flex-shrink:0 to the badge so it always
renders in full.
2026-05-01 19:40:12 +03:00
alexei.dolgolyov d27484a46d ui(player): square vinyl stage, brighter tonearm, tilted sleeve
- Restore 1:1 aspect-ratio on .vinyl-stage; the previous 1:0.85
  override created an inconsistent crop on resize. Replace the
  tonearm sibling's aspect-ratio with explicit height:36% so its
  vertical span tracks the stage instead of its own width.
- Brighten the tonearm SVG: lighter pivot/arm gradient stops,
  thicker stroke widths, stronger cartridge highlight.
- Add a subtle -2deg tilt to the sleeve so it reads as physically
  resting on the disc rather than rectilinearly composed.
2026-05-01 19:40:04 +03:00
69 changed files with 6551 additions and 1351 deletions
+10
View File
@@ -60,6 +60,16 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install native deps for dbus-python + PyGObject
run: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.0-dev
- name: Build Linux distribution - name: Build Linux distribution
run: | run: |
chmod +x build-dist-linux.sh chmod +x build-dist-linux.sh
+79
View File
@@ -61,6 +61,10 @@ jobs:
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` | | Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` | | Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
''').strip()) ''').strip())
# TODO(macos-runner): re-add the macOS rows below once a macOS
# runner is connected to Gitea and the build-macos job is re-enabled.
# | macOS (Apple Silicon) | \`MediaServer-{tag}-macos-arm64.tar.gz\` |
# | macOS (Intel) | \`MediaServer-{tag}-macos-x86_64.tar.gz\`
print(json.dumps('\n\n'.join(sections))) print(json.dumps('\n\n'.join(sections)))
") ")
@@ -187,6 +191,16 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install native deps for dbus-python + PyGObject
run: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.0-dev
- name: Build Linux distribution - name: Build Linux distribution
run: | run: |
chmod +x build-dist-linux.sh chmod +x build-dist-linux.sh
@@ -226,3 +240,68 @@ jobs:
-H "Authorization: token $DEPLOY_TOKEN" \ -H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@$FILE" --data-binary "@$FILE"
# --- Build macOS tarball (best-effort; requires a macos runner) ---
# PyObjC wheels are macOS-only, so this job must run on a real Mac.
#
# TODO(macos-runner): Temporarily disabled via `if: false` because the
# Gitea instance currently has no macOS runner attached. To re-enable:
# 1. Connect a macOS runner to Gitea
# 2. Delete the `if: false` line below
# 3. Restore the macOS rows in the Downloads table generated by the
# create-release job (search for the matching TODO(macos-runner)).
build-macos:
needs: create-release
if: false
runs-on: macos-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build macOS distribution
run: |
chmod +x build-dist-macos.sh
./build-dist-macos.sh "${{ gitea.ref_name }}"
- name: Upload assets to release
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
FILE=$(ls build/MediaServer-*-macos-*.tar.gz | head -1)
NAME=$(basename "$FILE")
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $DEPLOY_TOKEN")
ASSET_ID=$(echo "$EXISTING" | python3 -c "
import sys, json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '$NAME':
print(a['id'])
break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
+70
View File
@@ -34,3 +34,73 @@ jobs:
- name: Test - name: Test
run: pytest --tb=short -q || test $? -eq 5 run: pytest --tb=short -q || test $? -eq 5
# Linux smoke test: install the linux extra in the same way build-dist-linux.sh
# does, then boot the server and hit /api/health. Catches dependency-resolution
# and import-time regressions for the Linux distribution path.
linux-smoke:
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Linux system deps for dbus-python + PyGObject
run: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.0-dev
- name: Install with linux extra
run: |
pip install --upgrade pip
pip install ".[linux]"
- name: Smoke — server boots and /api/health responds
run: |
# Headless Linux runners have no PulseAudio and no display
# server, so we disable the visualizer + update checker, and we
# use `dbus-run-session` to give LinuxMediaController a real
# session bus to talk to (otherwise dbus.SessionBus() would
# raise during startup). This isn't a full MPRIS integration
# test — it only proves the dispatcher selects the Linux
# controller, all imports resolve, and /api/health returns 200.
sudo apt-get install -y --no-install-recommends dbus-x11
export MEDIA_SERVER_VISUALIZER_ENABLED=false
export MEDIA_SERVER_UPDATE_CHECK_ENABLED=false
dbus-run-session -- bash -c '
# First run writes a default config (random token) and exits 0
# instead of serving, so the server is never left running in
# insecure no-auth mode. Run once to seed the config; the real
# launch below then finds it and actually boots. /api/health needs
# no auth, so the generated token is irrelevant here.
python -m media_server.main --no-tray --port 18765 || true
python -m media_server.main --no-tray --port 18765 &
SERVER_PID=$!
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:18765/api/health" >/dev/null; then
echo "Health check passed"
kill $SERVER_PID
wait $SERVER_PID 2>/dev/null || true
exit 0
fi
sleep 0.5
done
echo "Server did not respond within 15s"
kill $SERVER_PID 2>/dev/null || true
exit 1
'
+59 -11
View File
@@ -285,37 +285,44 @@ All connected WebSocket clients receive a `links_changed` notification when link
## Installation ## Installation
Dependencies are declared in `pyproject.toml`. Pick the extra that matches
your OS — the Python deps differ enough between Windows / Linux / macOS
that there's no single `pip install` line.
### Installing on Windows ### Installing on Windows
```bash ```bash
pip install -r requirements.txt pip install ".[windows]"
``` ```
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes` Pulls in `winsdk`, `pywin32`, `pycaw`, `comtypes`, `pystray`, etc.
### Installing on Linux ### Installing on Linux
```bash ```bash
# Install system dependencies # System packages required to build dbus-python + PyGObject from sdist.
sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev sudo apt-get install -y python3-pip python3-venv \
libdbus-1-dev libglib2.0-dev pkg-config
pip install -r requirements.txt pip install ".[linux]"
``` ```
### Installing on macOS ### Installing on macOS
```bash ```bash
pip install -r requirements.txt pip install ".[macos]"
``` ```
No additional dependencies - uses built-in `osascript`. Pulls in `pyobjc-framework-Cocoa` + `pyobjc-framework-Quartz` for the
foreground-window probe; AppleScript-based media control uses the
built-in `osascript`.
### Installing on Android (Termux) ### Installing on Android (Termux)
```bash ```bash
# In Termux # In Termux
pkg install python termux-api pkg install python termux-api
pip install -r requirements.txt pip install "."
``` ```
Requires Termux and Termux:API apps from F-Droid. Requires Termux and Termux:API apps from F-Droid.
@@ -835,11 +842,19 @@ Install:
sudo ./service/install_linux.sh install sudo ./service/install_linux.sh install
``` ```
Enable and start for your user: **Enable user lingering** — required so `/run/user/$UID/bus` (the D-Bus
session socket needed for MPRIS) exists even when no graphical session
is active. Without this the server boots but every `/api/media/*` call
silently returns idle.
```bash ```bash
sudo systemctl enable media-server@$USER sudo loginctl enable-linger $USER
sudo systemctl start media-server@$USER ```
Enable and start the templated unit for your user:
```bash
sudo systemctl enable --now media-server@$USER
``` ```
View logs: View logs:
@@ -848,6 +863,39 @@ View logs:
journalctl -u media-server@$USER -f journalctl -u media-server@$USER -f
``` ```
**Troubleshooting:**
- *"`/api/media/status` always returns `idle`"* — check the service log for
`D-Bus session bus not available`. Most commonly: lingering isn't
enabled, or the unit is using the wrong `XDG_RUNTIME_DIR` (`%U` must
expand to the user's numeric UID).
- *"Visualizer permanently unavailable"* — PulseAudio/PipeWire must
expose monitor sources. `pactl list sources short | grep monitor`
should list at least one entry; if not, install `pipewire-pulse` and
restart your session.
- *"Volume control silently fails"* — `pactl` must be on `PATH` and the
user's PulseAudio/PipeWire server must be reachable
(`PULSE_RUNTIME_PATH=/run/user/$UID/pulse`).
- *"Foreground window is always `null`"* — expected under Wayland; the
compositor hides window info from unprivileged clients. X11 sessions
work normally.
### macOS (LaunchAgent)
The distribution tarball ships an installer:
```bash
./install-launchagent.sh
```
This drops `~/Library/LaunchAgents/com.dolgolyov.media-server.plist`,
starts the service immediately, and re-launches it at every login. Logs
go to `~/Library/Logs/media-server/{stdout,stderr}.log`. To stop:
```bash
./uninstall-launchagent.sh
```
## Command Line Options ## Command Line Options
```text ```text
+33 -10
View File
@@ -1,19 +1,42 @@
## v0.2.2 (2026-05-01) ## v0.4.0 (2026-05-28)
### UI / Player Two headline changes since v0.3.1: **Media Server now ships first-class Linux and macOS builds** (no more Windows-only), and the **Windows app icon was redesigned** with a proper multi-resolution ICO so the installer, Start Menu, desktop shortcut, Alt+Tab, and system tray all render sharp.
- Replace sticky footer with a dedicated **About** dialog opened from a new header button — frees up bottom space and removes the always-visible colophon strip ([ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781)) ### Features
- Reclaim dead space on the player view: drop ~64 px of bottom container padding now that the footer is gone ([ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781))
- Loosen the vinyl stage aspect ratio (`1:1``1:0.85`) and switch the tonearm from `height: 36%` to `aspect-ratio: 1` so the disc no longer leaves a tall empty band below the sleeve ([ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781)) - **Linux support, production-ready.** New `linux` extra (`dbus-python`, `PyGObject`, `python-xlib`) plus a `build-dist-linux.sh` that emits a portable tarball and a systemd user unit. The unit sets `DBUS_SESSION_BUS_ADDRESS`, `XDG_RUNTIME_DIR`, and `ReadWritePaths` for `~/.config` / `~/.cache` so MPRIS works and audit-log / thumbnail writes aren't blocked by `ProtectHome`. The Linux MPRIS controller now connects to the session bus lazily — a missing or late bus no longer crashes lifespan startup, and the user is logged a one-line hint about `loginctl enable-linger`. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- Add `about.*` and `dialog.close` i18n keys for **EN** and **RU** ([ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781)) - **macOS support.** New `macos` extra (`pyobjc-framework-Cocoa`, `pyobjc-framework-Quartz`), a `build-dist-macos.sh` script, and a per-user LaunchAgent installer producing `MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz` artifacts. Spotify URL artwork is wired through `MediaController.get_album_art()`. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Cross-platform album artwork.** `MediaController.get_album_art()` is now abstract with Linux (`mpris:artUrl`, `file://` + `http(s)://`) and macOS (Spotify URL) implementations; the `/api/media/artwork` endpoint awaits the controller. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Per-OS unavailable reasons on `/api/media/visualizer/status`** so the Web UI can explain *why* the visualizer is off on Linux / macOS instead of just hiding it. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Startup preflight on Linux** warns when `DBUS_SESSION_BUS_ADDRESS` or `XDG_RUNTIME_DIR` is unset and informs the user when running under Wayland disables the foreground-window probe — so silent loss of features is now diagnosable from the log. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Redesigned app icon ("Beacon").** Replaces the generic Spotify-green circle with a refined squircle + deep-teal diagonal gradient (`#0B3D3B → #1A6B5E`) + warm parchment play triangle (`#F5F1E8`) with a drop shadow, top sheen, and ghosted echo-chevrons that hint at broadcast/stream. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **Multi-resolution Windows ICO.** `icon.ico` grew from a single 16×16 frame (208 B) to a 10-frame ICO (16/20/24/32/40/48/64/96/128/256 — ~37 KB) so Windows no longer upscales 16×16 into mush for the installer chrome, Start Menu, desktop shortcuts, Alt+Tab, and File Explorer tiles. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **System tray uses the new icon.** `tray.py` now picks a 64×64 frame from the multi-res ICO; the procedural fallback was reskinned to the same Beacon palette so a missing ICO no longer regresses the tray back to the old Spotify-green circle. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
### Bug Fixes
- **`tray._confirm` guarded against `ctypes.windll` on non-Windows** so the new Linux / macOS builds don't crash when the tray prompts for confirmation. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **`config.example.yaml` defaults are now cross-platform.** Per-OS commented examples for the on/off scripts, and `on_turn_off` defaults to a harmless `echo` (the previous default silently failed everywhere but Windows). ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
--- ---
### Development / Internal ### Development / Internal
#### Chores #### Build & Packaging
- Wire up the **code-review-graph** MCP server: add `.mcp.json` (uvx, stdio), document the graph tools in `CLAUDE.md` so structural exploration prefers graph queries over Grep/Read, and ignore the `.code-review-graph/` index directory ([e7372b0](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e7372b0)) - **`scripts/generate-icon.py`** — SVG is the canonical source; `resvg-py` rasterizes every ICO size; Pillow packs the multi-resolution `icon.ico`. Re-run any time the SVG changes. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **Dependency reorganization in `pyproject.toml`** — `screen-brightness-control` and `monitorcontrol` are cross-platform and moved to base deps; new `linux` and `macos` optional-deps groups with `sys_platform` markers. `resvg-py` added to `[dev]` for the icon-generation script. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c), [d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **`install_linux.sh`** — dropped the dead `requirements.txt` path; now installs via `pip install ".[linux]"` and pre-creates the writable state dirs. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Templated `media-server.service`** updated to match the new dist layout, with proper session-bus env vars and a writable state-dir grant. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
#### CI
- **New `linux-smoke` job** in `.gitea/workflows/test.yml` — installs `.[linux]`, boots the server under `dbus-run-session`, and asserts `/api/health`. Catches dependency-resolution and import-time regressions for the Linux dist path. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Release workflow gains apt-deps step** for the Linux build and a best-effort macOS build job that produces the per-arch macOS tarballs. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
#### Documentation
- **README rewritten for the new extras** — replaces the stale `pip install -r requirements.txt` instructions, adds a systemd-lingering note + troubleshooting section, and a macOS LaunchAgent section. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
--- ---
@@ -22,7 +45,7 @@
| Hash | Message | Author | | Hash | Message | Author |
|------|---------|--------| |------|---------|--------|
| [ec51781](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ec51781) | ui(player): replace footer with About dialog + reclaim dead space | alexei.dolgolyov | | [d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed) | feat(icon): redesign app icon as "Beacon" and ship multi-resolution ICO | alexei.dolgolyov |
| [e7372b0](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e7372b0) | chore: wire up code-review-graph MCP server | alexei.dolgolyov | | [ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c) | feat: production-ready Linux & macOS support | alexei.dolgolyov |
</details> </details>
+54 -1
View File
@@ -16,12 +16,38 @@ BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
clean_dist "${DIST_DIR}" build clean_dist "${DIST_DIR}" build
verify_frontend verify_frontend
# --- Sanity check: native deps required for dbus-python + PyGObject ---
# These compile from sdist on Linux. We don't try to install them via the
# distro package manager here (the CI image must already have them); fail
# with a clear message instead of cryptic gcc/pkg-config errors deep
# inside pip.
need_pkg() {
local pkg="$1"
if ! command -v pkg-config >/dev/null 2>&1; then
echo "ERROR: pkg-config is required (apt: pkg-config / dnf: pkgconf-pkg-config)" >&2
exit 1
fi
if ! pkg-config --exists "$pkg" 2>/dev/null; then
echo "ERROR: $pkg headers missing — install libdbus-1-dev libglib2.0-dev (apt)" >&2
echo " or dbus-devel glib2-devel (dnf) before running this script." >&2
exit 1
fi
}
need_pkg dbus-1
need_pkg glib-2.0
# --- Create self-contained virtualenv --- # --- Create self-contained virtualenv ---
echo "Creating virtualenv..." echo "Creating virtualenv..."
python3 -m venv "${DIST_DIR}/venv" python3 -m venv "${DIST_DIR}/venv"
source "${DIST_DIR}/venv/bin/activate" source "${DIST_DIR}/venv/bin/activate"
pip install --quiet --upgrade pip pip install --quiet --upgrade pip
pip install --quiet "." # Install with the linux extra so dbus-python / PyGObject / python-xlib land
# in the venv. Falls back to base deps if the extra is unsupported (shouldn't
# happen post-0.3.2 but keep the fallback for older tags re-built here).
if ! pip install --quiet ".[linux]"; then
echo "WARN: '.[linux]' extra unavailable; installing base deps only" >&2
pip install --quiet "."
fi
# Remove the installed package (app source is on PYTHONPATH via launcher) # Remove the installed package (app source is on PYTHONPATH via launcher)
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server* rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
@@ -47,6 +73,11 @@ LAUNCHER
chmod +x "${DIST_DIR}/media-server.sh" chmod +x "${DIST_DIR}/media-server.sh"
# --- Create systemd service installer --- # --- Create systemd service installer ---
# Emits a non-templated unit that runs as the invoking user with the env
# bits MPRIS / PulseAudio need: DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR
# must be set or DBus.SessionBus() either fails or talks to the wrong bus.
# We also relax ProtectHome to read-write for ~/.config/media-server +
# ~/.cache/media-server so audit.log and thumbnail cache writes succeed.
cat > "${DIST_DIR}/install-service.sh" << 'SERVICE' cat > "${DIST_DIR}/install-service.sh" << 'SERVICE'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -61,6 +92,9 @@ if [ "$EUID" -ne 0 ]; then
fi fi
REAL_USER="${SUDO_USER:-$USER}" REAL_USER="${SUDO_USER:-$USER}"
USER_UID="$(id -u "$REAL_USER")"
USER_HOME="$(getent passwd "$REAL_USER" | cut -d: -f6)"
RUNTIME_DIR="/run/user/${USER_UID}"
cat > "$SERVICE_FILE" << EOF cat > "$SERVICE_FILE" << EOF
[Unit] [Unit]
@@ -74,17 +108,36 @@ WorkingDirectory=${SCRIPT_DIR}
ExecStart=${SCRIPT_DIR}/media-server.sh ExecStart=${SCRIPT_DIR}/media-server.sh
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# Required by MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=HOME=${USER_HOME}
Environment=XDG_RUNTIME_DIR=${RUNTIME_DIR}
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=${RUNTIME_DIR}/bus
# Light sandboxing — keep ~/.config/media-server + ~/.cache/media-server
# writable so audit.log / thumbnails / config CRUD continue to work.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=${USER_HOME}/.config/media-server ${USER_HOME}/.cache/media-server
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# Pre-create state dirs so ReadWritePaths actually has something to grant.
sudo -u "$REAL_USER" mkdir -p \
"${USER_HOME}/.config/media-server" \
"${USER_HOME}/.cache/media-server"
systemctl daemon-reload systemctl daemon-reload
systemctl enable "${SERVICE_NAME}" systemctl enable "${SERVICE_NAME}"
systemctl start "${SERVICE_NAME}" systemctl start "${SERVICE_NAME}"
echo "Service '${SERVICE_NAME}' installed and started." echo "Service '${SERVICE_NAME}' installed and started."
echo "Check status: systemctl status ${SERVICE_NAME}" echo "Check status: systemctl status ${SERVICE_NAME}"
echo "Tail logs: journalctl -u ${SERVICE_NAME} -f"
SERVICE SERVICE
chmod +x "${DIST_DIR}/install-service.sh" chmod +x "${DIST_DIR}/install-service.sh"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
# Build macOS distribution (self-contained venv + tarball)
# Usage: ./build-dist-macos.sh [VERSION]
#
# Must be run on macOS (PyObjC wheels are platform-specific). For CI use
# the github-hosted macos-latest runner.
source "$(dirname "$0")/build-common.sh"
if [ "$(uname -s)" != "Darwin" ]; then
echo "ERROR: build-dist-macos.sh must run on macOS (uname=$(uname -s))" >&2
exit 1
fi
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for macOS"
# Detect host architecture for archive naming (arm64 = Apple Silicon, x86_64 = Intel).
ARCH="$(uname -m)"
DIST_DIR="dist/media-server"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-macos-${ARCH}"
clean_dist "${DIST_DIR}" build
verify_frontend
# --- Create self-contained virtualenv ---
echo "Creating virtualenv..."
python3 -m venv "${DIST_DIR}/venv"
# shellcheck disable=SC1091
source "${DIST_DIR}/venv/bin/activate"
pip install --quiet --upgrade pip
if ! pip install --quiet ".[macos]"; then
echo "WARN: '.[macos]' extra unavailable; installing base deps only" >&2
pip install --quiet "."
fi
# Remove the installed package (app source is on PYTHONPATH via launcher)
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
deactivate
# Trim venv site-packages — macOS native ext is .so, dylibs are .dylib
MAC_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
cleanup_site_packages "$MAC_SP" "so" "dylib"
copy_app_files "$DIST_DIR"
# --- Launcher ---
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/app"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/venv/bin/activate"
exec python -m media_server.main "$@"
LAUNCHER
chmod +x "${DIST_DIR}/media-server.sh"
# --- LaunchAgent installer ---
# LaunchAgents run as the user, with the user's GUI session — exactly what
# we want for AppleScript / Music.app / Spotify control. KeepAlive auto-
# restarts on crash; RunAtLoad starts at login.
cat > "${DIST_DIR}/install-launchagent.sh" << 'LAUNCHAGENT'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LABEL="com.dolgolyov.media-server"
PLIST_DIR="${HOME}/Library/LaunchAgents"
PLIST="${PLIST_DIR}/${LABEL}.plist"
LOG_DIR="${HOME}/Library/Logs/media-server"
mkdir -p "$PLIST_DIR" "$LOG_DIR"
cat > "$PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${SCRIPT_DIR}/media-server.sh</string>
<string>--no-tray</string>
</array>
<key>WorkingDirectory</key>
<string>${SCRIPT_DIR}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>${LOG_DIR}/stdout.log</string>
<key>StandardErrorPath</key>
<string>${LOG_DIR}/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
</dict>
</plist>
EOF
# Reload if already loaded, then load fresh.
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load -w "$PLIST"
echo "LaunchAgent '${LABEL}' installed and started."
echo "Plist: $PLIST"
echo "Logs: $LOG_DIR/{stdout,stderr}.log"
echo "Stop: launchctl unload \"$PLIST\""
echo "Start: launchctl load -w \"$PLIST\""
LAUNCHAGENT
chmod +x "${DIST_DIR}/install-launchagent.sh"
# Convenience uninstaller.
cat > "${DIST_DIR}/uninstall-launchagent.sh" << 'UNINSTALL'
#!/usr/bin/env bash
set -euo pipefail
LABEL="com.dolgolyov.media-server"
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
echo "LaunchAgent '${LABEL}' removed (config preserved under ~/.config/media-server)."
UNINSTALL
chmod +x "${DIST_DIR}/uninstall-launchagent.sh"
# --- Package ---
echo "Creating archive..."
cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
tar -czf "${BUILD_OUTPUT}.tar.gz" -C build "MediaServer-v${VERSION_CLEAN}-macos-${ARCH}"
echo "Build complete: ${BUILD_OUTPUT}.tar.gz"
+96 -58
View File
@@ -1,7 +1,13 @@
# 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.
# By default, authentication is DISABLED (no tokens = open access). #
# To enable auth, uncomment and configure the api_tokens section below. # Secure-by-default: the server binds to loopback (127.0.0.1) only and refuses
# to bind a non-loopback address with no tokens configured.
#
# To expose on the LAN you must do ONE of:
# 1. Configure api_tokens below AND change host to "0.0.0.0", OR
# 2. Set `allow_lan_without_auth: true` (LAN-open, no auth — insecure on
# hostile networks, only acceptable on a trusted home LAN).
# 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
@@ -11,112 +17,144 @@
# web_ui: "your-web-ui-token-here" # web_ui: "your-web-ui-token-here"
# Server settings # Server settings
host: "0.0.0.0" host: "127.0.0.1"
port: 8765 port: 8765
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
# ─── Custom scripts ─────────────────────────────────────────────────────
#
# Examples below are platform-specific. Uncomment the block that matches
# your OS — YAML keys must be unique, so don't ship multiple
# `lock_screen:` entries.
# Custom scripts
scripts: scripts:
lock_screen: # ── Windows ─────────────────────────────────────────────────────────
command: "rundll32.exe user32.dll,LockWorkStation" # lock_screen:
label: "Lock Screen" # command: "rundll32.exe user32.dll,LockWorkStation"
description: "Lock the workstation" # label: "Lock Screen"
icon: "mdi:lock" # description: "Lock the workstation"
timeout: 5 # icon: "mdi:lock"
shell: true # timeout: 5
# shell: true
# sleep:
# command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 10
# shell: true
# shutdown:
# command: "shutdown /s /t 0"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
hibernate: # ── Linux (systemd / xdg) ───────────────────────────────────────────
command: "shutdown /h" # lock_screen:
label: "Hibernate" # command: "loginctl lock-session" # or: xdg-screensaver lock
description: "Hibernate the PC" # label: "Lock Screen"
icon: "mdi:power-sleep" # icon: "mdi:lock"
# timeout: 5
# shell: true
# sleep:
# command: "systemctl suspend"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 10
# shell: true
# shutdown:
# command: "systemctl poweroff"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
# ── macOS ───────────────────────────────────────────────────────────
# lock_screen:
# command: "pmset displaysleepnow"
# label: "Lock Screen"
# icon: "mdi:lock"
# timeout: 5
# shell: true
# sleep:
# command: "pmset sleepnow"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 5
# shell: true
# shutdown:
# command: "osascript -e 'tell application \"System Events\" to shut down'"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
# Cross-platform smoke test (always defined so first-run users see the UI populate).
example_script:
command: "echo Hello from Media Server!"
description: "Example script - echoes a message"
timeout: 10 timeout: 10
shell: true shell: true
sleep: # Media folder management from Web UI (default: false)
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
label: "Sleep"
description: "Put PC to sleep"
icon: "mdi:sleep"
timeout: 10
shell: true
shutdown:
command: "shutdown /s /t 0"
label: "Shutdown"
description: "Shutdown the PC immediately"
icon: "mdi:power"
timeout: 10
shell: true
restart:
command: "shutdown /r /t 0"
label: "Restart"
description: "Restart the PC immediately"
icon: "mdi:restart"
timeout: 10
shell: true
# Media folder management from Web UI (default: true)
# When enabled, media folders can be added, edited, and deleted from the Settings tab. # When enabled, media folders can be added, edited, and deleted from the Settings tab.
# Set to false to disable folder management from the UI. # media_folders_management: true
# media_folders_management: false
# ─── Callback scripts (executed by integration events) ──────────────────
#
# Callbacks are OS-agnostic — if you want side effects, fill in commands
# below. The defaults are no-op echos so first-run logs don't error out.
# Callback scripts (executed after media actions)
# All callbacks are optional - if not defined, the action runs without callback
callbacks: callbacks:
# Media control callbacks (run after successful action)
on_play: on_play:
command: "echo Play triggered" command: "echo Play triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_pause: on_pause:
command: "echo Pause triggered" command: "echo Pause triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_stop: on_stop:
command: "echo Stop triggered" command: "echo Stop triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_next: on_next:
command: "echo Next track" command: "echo Next track"
timeout: 10 timeout: 10
shell: true shell: true
on_previous: on_previous:
command: "echo Previous track" command: "echo Previous track"
timeout: 10 timeout: 10
shell: true shell: true
on_volume: on_volume:
command: "echo Volume changed" command: "echo Volume changed"
timeout: 10 timeout: 10
shell: true shell: true
on_mute: on_mute:
command: "echo Mute toggled" command: "echo Mute toggled"
timeout: 10 timeout: 10
shell: true shell: true
on_seek: on_seek:
command: "echo Seek triggered" command: "echo Seek triggered"
timeout: 10 timeout: 10
shell: true shell: true
# Turn on/off/toggle (callback-only actions, no default behavior) # Turn on/off/toggle (callback-only actions, no default behavior).
# The Windows-only example used to ship as the default for `on_turn_off`,
# which silently failed on Linux/macOS. Defaults are now no-ops — pick a
# platform-appropriate command below.
on_turn_on: on_turn_on:
command: "echo Turn on callback" command: "echo Turn on callback"
timeout: 10 timeout: 10
shell: true shell: true
on_turn_off: on_turn_off:
command: "rundll32.exe user32.dll,LockWorkStation" # Windows: "rundll32.exe user32.dll,LockWorkStation"
# Linux: "loginctl lock-session"
# macOS: "pmset displaysleepnow"
command: "echo Turn off callback"
timeout: 5 timeout: 5
shell: true shell: true
on_toggle: on_toggle:
command: "echo Toggle callback" command: "echo Toggle callback"
timeout: 10 timeout: 10
shell: true shell: true
+17 -3
View File
@@ -1,18 +1,32 @@
"""Media Server - REST API for controlling system media playback.""" """Media Server - REST API for controlling system media playback."""
import re
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
def _detect_version() -> str: def _detect_version() -> str:
# 1. Package metadata (works when pip-installed in dev) # 1. Live pyproject.toml — only present in dev checkouts. Prefer this
# over installed package metadata so `pip install -e .` users don't
# see stale versions after editing pyproject.toml without reinstalling.
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
if pyproject.is_file():
try:
match = _VERSION_RE.search(pyproject.read_text(encoding="utf-8"))
if match:
return match.group(1)
except OSError:
pass
# 2. Package metadata (works for any pip-installed copy).
try: try:
return version("media-server") return version("media-server")
except PackageNotFoundError: except PackageNotFoundError:
pass pass
# 2. VERSION file written by build scripts (production builds) # 3. VERSION file written by build scripts (production builds).
# Located at install root, two levels up from this package
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION" version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
if version_file.is_file(): if version_file.is_file():
return version_file.read_text().strip() return version_file.read_text().strip()
+34 -2
View File
@@ -13,6 +13,8 @@ security = HTTPBearer(auto_error=False)
# Context variable to store current request's token label # Context variable to store current request's token label
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown") token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
# Per-request correlation ID — generated in middleware if upstream didn't send one.
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
def auth_enabled() -> bool: def auth_enabled() -> bool:
@@ -29,12 +31,42 @@ def get_token_label(token: str) -> Optional[str]:
Returns: Returns:
The label for the token, or None if invalid The label for the token, or None if invalid
""" """
for label, stored_token in settings.api_tokens.items(): for label, spec in settings.api_tokens.items():
if secrets.compare_digest(stored_token, token): if secrets.compare_digest(spec.token, token):
return label return label
return None return None
def token_has_scope(label: str, required: str) -> bool:
"""Whether the token identified by `label` grants `required` scope."""
spec = settings.api_tokens.get(label)
if spec is None:
# Unknown label = no auth or anonymous; treat as full access only
# when auth is disabled entirely (matches existing behaviour).
return not auth_enabled()
return spec.grants(required)
def require_scope(scope: str):
"""Build a FastAPI dependency that enforces the given scope.
Use as ``Depends(require_scope("admin"))`` on management endpoints. When
auth is disabled the dependency is a no-op (anonymous access).
"""
async def _checker(label: str = Depends(verify_token)) -> str:
if not auth_enabled():
return label
if not token_has_scope(label, scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: {scope}",
)
return label
return _checker
async def verify_token( async def verify_token(
request: Request, request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
+223 -30
View File
@@ -1,13 +1,54 @@
"""Configuration management for the media server.""" """Configuration management for the media server."""
import logging
import os import os
import secrets
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import yaml import yaml
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
# Token scopes form a strict hierarchy: admin > control > read. Helper utility
# used by both auth.py and the validator below.
SCOPE_HIERARCHY: dict[str, frozenset[str]] = {
"read": frozenset({"read"}),
"control": frozenset({"read", "control"}),
"admin": frozenset({"read", "control", "admin"}),
}
ALL_SCOPES: frozenset[str] = frozenset(SCOPE_HIERARCHY.keys())
class TokenSpec(BaseModel):
"""Per-token authentication entry with explicit scopes."""
token: str = Field(..., min_length=8, description="Secret token value")
scopes: list[str] = Field(
default_factory=lambda: ["admin"],
description="Granted scopes (subset of read|control|admin).",
)
@field_validator("scopes")
@classmethod
def _validate_scopes(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("scopes must list at least one of read|control|admin")
unknown = set(v) - ALL_SCOPES
if unknown:
raise ValueError(f"unknown scopes: {sorted(unknown)}; valid={sorted(ALL_SCOPES)}")
return v
def grants(self, required: str) -> bool:
"""Whether this token grants the requested scope (with hierarchy expansion)."""
granted: set[str] = set()
for s in self.scopes:
granted |= SCOPE_HIERARCHY.get(s, frozenset({s}))
return required in granted
class MediaFolderConfig(BaseModel): class MediaFolderConfig(BaseModel):
"""Configuration for a media folder.""" """Configuration for a media folder."""
@@ -44,6 +85,13 @@ class ScriptParameterConfig(BaseModel):
options: Optional[list[str]] = Field( options: Optional[list[str]] = Field(
default=None, description="Allowed values (select type only)" default=None, description="Allowed values (select type only)"
) )
pattern: Optional[str] = Field(
default=None,
description=(
"Optional regex (Python flavour) that string-typed values must match."
" Use to harden parameters that flow into shell=true scripts."
),
)
class ScriptConfig(BaseModel): class ScriptConfig(BaseModel):
@@ -81,14 +129,106 @@ class Settings(BaseSettings):
) )
# Server settings # Server settings
host: str = Field(default="0.0.0.0", description="Server bind address") host: str = Field(
port: int = Field(default=8765, description="Server port") default="127.0.0.1",
description=(
# Authentication (empty = auth disabled, anyone can access the API) "Server bind address. Use 127.0.0.1 for loopback-only (default, safest),"
api_tokens: dict[str, str] = Field( " or 0.0.0.0 to expose on the LAN (requires api_tokens to be set)."
default_factory=dict, ),
description="Named API tokens for access control (label: token pairs). Empty = no auth.",
) )
port: int = Field(default=8765, description="Server port")
allow_lan_without_auth: bool = Field(
default=False,
description=(
"Allow binding to a non-loopback address with no api_tokens configured."
" Off by default to prevent unauthenticated LAN exposure."
),
)
cors_origins: list[str] = Field(
default_factory=list,
description=(
"Allowed CORS origins. Empty (default) means only same-origin requests"
" from http://localhost:<port> and http://127.0.0.1:<port>."
),
)
# Reverse-proxy deployment: when serving the API behind nginx/Caddy/Traefik,
# uvicorn must trust the X-Forwarded-* headers from the proxy so that the
# `Origin` allow-list, request URLs, and logs reflect the public-facing
# values. Off by default — only enable when there's a real proxy in front
# (otherwise clients can spoof their own IP).
proxy_headers: bool = Field(
default=False,
description="Honor X-Forwarded-For / X-Forwarded-Proto from upstream proxy.",
)
forwarded_allow_ips: str = Field(
default="127.0.0.1",
description=(
"Comma-separated IPs / CIDRs that uvicorn should trust X-Forwarded-* from."
" Use '*' to trust all (only safe when bound to a private interface)."
),
)
# HTTPS / TLS. Both must be set together to enable TLS; if only one is set
# the server refuses to start. Use `mkcert` or letsencrypt to generate the
# pair; the server reads them at startup.
ssl_certfile: Optional[str] = Field(
default=None,
description="Path to TLS certificate (PEM). Pair with ssl_keyfile.",
)
ssl_keyfile: Optional[str] = Field(
default=None,
description="Path to TLS private key (PEM). Pair with ssl_certfile.",
)
ssl_keyfile_password: Optional[str] = Field(
default=None,
description="Optional password for the private key if encrypted.",
)
# Admin-grade operations (script / callback / link / folder create/update/delete).
# When True the same token used for read/play can also persist arbitrary shell
# commands. Default False so a single leaked token cannot escalate to RCE; opt
# in explicitly to manage scripts/callbacks/links via the Web UI.
scripts_management: bool = Field(default=False, description="Allow scripts CRUD via API")
callbacks_management: bool = Field(default=False, description="Allow callbacks CRUD via API")
links_management: bool = Field(default=False, description="Allow links CRUD via API")
# Authentication (empty = auth disabled, anyone can access the API).
#
# Each entry can be either:
# • a bare string (legacy form, treated as scopes = ["admin"] for back-compat), OR
# • a mapping with explicit scopes, e.g.
# "ha": {token: "<token>", scopes: ["read", "control"]}
# "kiosk": {token: "<token>", scopes: ["read"]}
# "ops": {token: "<token>", scopes: ["admin"]}
#
# Available scopes:
# read — GET /api/* (status, list, browse) but no state-changing calls.
# control — read + media transport, display/audio, script EXECUTE, callback EXECUTE.
# admin — control + CRUD on scripts/callbacks/links/folders.
#
# Validation normalises both forms to TokenSpec at load time.
api_tokens: dict[str, TokenSpec] = Field(
default_factory=dict,
description=(
"Named API tokens. Value can be a bare token string (= admin scope) or"
" a {token, scopes} mapping. See TokenSpec for scope definitions."
),
)
@field_validator("api_tokens", mode="before")
@classmethod
def _normalise_tokens(cls, v):
"""Accept legacy `label: <bare-token>` form and promote to TokenSpec."""
if not isinstance(v, dict):
return v
out: dict[str, dict | TokenSpec] = {}
for label, entry in v.items():
if isinstance(entry, str):
out[label] = {"token": entry, "scopes": ["admin"]}
else:
out[label] = entry
return out
# Media controller settings # Media controller settings
poll_interval: float = Field( poll_interval: float = Field(
@@ -125,7 +265,7 @@ class Settings(BaseSettings):
description="Media folders available for browsing in the media browser", description="Media folders available for browsing in the media browser",
) )
media_folders_management: bool = Field( media_folders_management: bool = Field(
default=True, default=False,
description="Allow adding, editing, and deleting media folders from the Web UI", description="Allow adding, editing, and deleting media folders from the Web UI",
) )
@@ -218,21 +358,28 @@ def get_config_dir() -> Path:
def generate_default_config(path: Optional[Path] = None) -> Path: def generate_default_config(path: Optional[Path] = None) -> Path:
"""Generate a default configuration file with a new API token.""" """Generate a default configuration file with a freshly generated API token.
The token is written into ``api_tokens.default`` and printed to the logger
so first-run users can copy it. Subsequent runs preserve whatever the user
has set.
"""
if path is None: if path is None:
path = get_config_dir() / "config.yaml" path = get_config_dir() / "config.yaml"
default_token = secrets.token_urlsafe(32)
config = { config = {
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 8765, "port": 8765,
# "api_tokens": { # Default token grants "admin" scope (full access). To create a
# "default": "your-secret-token-here", # read-only or control-only token, add a second entry:
# }, # ha_readonly: {token: "<token>", scopes: ["read"]}
"api_tokens": {
"default": {"token": default_token, "scopes": ["admin"]},
},
"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)
# Set to null or remove to use default device
# "audio_device": "Speakers (Realtek",
"scripts": { "scripts": {
"example_script": { "example_script": {
"command": "echo Hello from Media Server!", "command": "echo Hello from Media Server!",
@@ -240,26 +387,72 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
"timeout": 10, "timeout": 10,
"shell": True, "shell": True,
}, },
# Add your custom scripts here:
# "shutdown": {
# "command": "shutdown /s /t 60",
# "description": "Shutdown computer in 60 seconds",
# "timeout": 5,
# },
# "lock_screen": {
# "command": "rundll32.exe user32.dll,LockWorkStation",
# "description": "Lock the workstation",
# "timeout": 5,
# },
}, },
} }
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f: _write_yaml_atomic(path, config)
yaml.dump(config, f, default_flow_style=False, sort_keys=False) _restrict_config_perms(path)
logger.info("Generated default config at %s", path)
logger.info("API token (label=default): %s", default_token)
return path return path
def _write_yaml_atomic(path: Path, data: dict) -> None:
"""Write YAML to disk atomically via tmp file + rename, with restricted perms."""
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
_restrict_config_perms(tmp)
os.replace(tmp, path)
def _restrict_config_perms(path: Path) -> None:
"""Ensure config file is readable only by its owner.
POSIX → ``chmod 0600``. On Windows the default NTFS ACL leaves the file
readable by every interactive user on the machine (Users group has Read),
which is bad given the file stores plaintext API tokens. Use ``icacls`` to
grant exclusive access to the current user + SYSTEM + Administrators and
strip inheritance.
"""
if os.name == "nt":
_restrict_config_perms_windows(path)
return
try:
os.chmod(path, 0o600)
os.chmod(path.parent, 0o700)
except OSError:
logger.debug("Could not chmod %s", path, exc_info=True)
def _restrict_config_perms_windows(path: Path) -> None:
"""Apply restrictive NTFS ACL to a config file (Windows only)."""
import subprocess
try:
username = os.environ.get("USERNAME") or os.environ.get("USER")
if not username:
logger.debug("Cannot detect current user; skipping icacls hardening")
return
# Disable inheritance and remove every existing ACE, then grant access
# only to current user, SYSTEM, and Administrators. /Q suppresses
# progress output; /C lets per-file errors not abort the batch.
subprocess.run(
["icacls", str(path), "/inheritance:r"],
check=False, capture_output=True, timeout=5,
)
for principal in (username, "SYSTEM", "Administrators"):
subprocess.run(
["icacls", str(path), "/grant:r", f"{principal}:(R,W)"],
check=False, capture_output=True, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
# `icacls` missing or sandboxed — leave the default ACL in place.
logger.debug("icacls hardening failed for %s", path, exc_info=True)
# Global settings instance # Global settings instance
settings = Settings.load_from_yaml() settings = Settings.load_from_yaml()
+147 -402
View File
@@ -1,52 +1,50 @@
"""Thread-safe configuration file manager for runtime script updates.""" """Thread-safe configuration file manager for runtime updates."""
import logging import logging
import os import os
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Optional
import yaml import yaml
from .config import CallbackConfig, LinkConfig, MediaFolderConfig, ScriptConfig, settings from .config import (
CallbackConfig,
LinkConfig,
MediaFolderConfig,
ScriptConfig,
_restrict_config_perms,
_write_yaml_atomic,
settings,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ConfigManager: class ConfigManager:
"""Thread-safe configuration file manager.""" """Thread-safe configuration file manager.
All writes go through ``_save()`` which writes to ``config.yaml.tmp`` and
then ``os.replace()``s it into place so a crash mid-write cannot corrupt
the only persistent user data. On POSIX the file is also chmodded to 0600
so co-tenant users cannot read the API token.
"""
def __init__(self, config_path: Optional[Path] = None): def __init__(self, config_path: Optional[Path] = None):
"""Initialize the config manager.
Args:
config_path: Path to config file. If None, will search standard locations.
"""
self._lock = threading.Lock() self._lock = threading.Lock()
self._config_path = config_path or self._find_config_path() self._config_path = config_path or self._find_config_path()
logger.info(f"ConfigManager initialized with path: {self._config_path}") logger.info(f"ConfigManager initialized with path: {self._config_path}")
def _find_config_path(self) -> Path: @staticmethod
"""Find the active config file path. def _find_config_path() -> Path:
"""Find the active config file path (or the default if none exists yet)."""
search_paths = [Path("config.yaml"), Path("config.yml")]
Returns: if os.name == "nt":
Path to the config file.
Raises:
FileNotFoundError: If no config file is found.
"""
# Same search logic as Settings.load_from_yaml()
search_paths = [
Path("config.yaml"),
Path("config.yml"),
]
# Add platform-specific config directory
if os.name == "nt": # Windows
appdata = os.environ.get("APPDATA", "") appdata = os.environ.get("APPDATA", "")
if appdata: if appdata:
search_paths.append(Path(appdata) / "media-server" / "config.yaml") search_paths.append(Path(appdata) / "media-server" / "config.yaml")
else: # Linux/Unix/macOS else:
search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml") search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml")
search_paths.append(Path("/etc/media-server/config.yaml")) search_paths.append(Path("/etc/media-server/config.yaml"))
@@ -54,7 +52,6 @@ class ConfigManager:
if search_path.exists(): if search_path.exists():
return search_path return search_path
# If not found, use the default location
if os.name == "nt": if os.name == "nt":
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml" default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
else: else:
@@ -63,422 +60,170 @@ class ConfigManager:
logger.warning(f"No config file found, using default path: {default_path}") logger.warning(f"No config file found, using default path: {default_path}")
return default_path return default_path
def add_script(self, name: str, config: ScriptConfig) -> None: def _load(self) -> dict[str, Any]:
"""Add a new script to config. """Read the config YAML, returning an empty dict if the file is missing."""
if not self._config_path.exists():
return {}
with open(self._config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
Args: def _save(self, data: dict[str, Any]) -> None:
name: Script name (must be unique). """Atomically write the config YAML and lock down its permissions."""
config: Script configuration. self._config_path.parent.mkdir(parents=True, exist_ok=True)
_write_yaml_atomic(self._config_path, data)
_restrict_config_perms(self._config_path)
Raises: # --- Generic per-section CRUD --------------------------------------
ValueError: If script already exists.
IOError: If config file cannot be written. def _upsert(
""" self,
section: str,
key: str,
value: Any,
*,
require_absent: bool = False,
require_present: bool = False,
in_memory_target: dict[str, Any] | None = None,
verb: str = "set",
) -> None:
with self._lock: with self._lock:
# Read YAML data = self._load()
if not self._config_path.exists(): existing = data.get(section, {})
data = {} if require_absent and key in existing:
else: raise ValueError(f"{section[:-1].title()} '{key}' already exists")
with open(self._config_path, "r", encoding="utf-8") as f: if require_present and (not isinstance(existing, dict) or key not in existing):
data = yaml.safe_load(f) or {} raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
# Check if script already exists if not isinstance(existing, dict):
if "scripts" in data and name in data["scripts"]: existing = {}
raise ValueError(f"Script '{name}' already exists") existing[key] = value.model_dump(exclude_none=True)
data[section] = existing
# Add script self._save(data)
if "scripts" not in data:
data["scripts"] = {}
data["scripts"][name] = config.model_dump(exclude_none=True)
# Write YAML if in_memory_target is not None:
self._config_path.parent.mkdir(parents=True, exist_ok=True) in_memory_target[key] = value
with open(self._config_path, "w", encoding="utf-8") as f: logger.info(f"{section[:-1].title()} '{key}' {verb} in config")
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings def _delete(
settings.scripts[name] = config self,
section: str,
key: str,
*,
in_memory_target: dict[str, Any] | None = None,
) -> None:
with self._lock:
data = self._load()
existing = data.get(section, {})
if not isinstance(existing, dict) or key not in existing:
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
del existing[key]
data[section] = existing
logger.info(f"Script '{name}' added to config") self._save(data)
if in_memory_target is not None and key in in_memory_target:
del in_memory_target[key]
logger.info(f"{section[:-1].title()} '{key}' deleted from config")
# --- Scripts -------------------------------------------------------
def add_script(self, name: str, config: ScriptConfig) -> None:
self._upsert(
"scripts", name, config,
require_absent=True,
in_memory_target=settings.scripts,
verb="added",
)
def update_script(self, name: str, config: ScriptConfig) -> None: def update_script(self, name: str, config: ScriptConfig) -> None:
"""Update an existing script. self._upsert(
"scripts", name, config,
Args: require_present=True,
name: Script name. in_memory_target=settings.scripts,
config: New script configuration. verb="updated",
)
Raises:
ValueError: If script does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if script exists
if "scripts" not in data or name not in data["scripts"]:
raise ValueError(f"Script '{name}' does not exist")
# Update script
data["scripts"][name] = config.model_dump(exclude_none=True)
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.scripts[name] = config
logger.info(f"Script '{name}' updated in config")
def delete_script(self, name: str) -> None: def delete_script(self, name: str) -> None:
"""Delete a script from config. self._delete("scripts", name, in_memory_target=settings.scripts)
Args: # --- Callbacks -----------------------------------------------------
name: Script name.
Raises:
ValueError: If script does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if script exists
if "scripts" not in data or name not in data["scripts"]:
raise ValueError(f"Script '{name}' does not exist")
# Delete script
del data["scripts"][name]
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if name in settings.scripts:
del settings.scripts[name]
logger.info(f"Script '{name}' deleted from config")
def add_callback(self, name: str, config: CallbackConfig) -> None: def add_callback(self, name: str, config: CallbackConfig) -> None:
"""Add a new callback to config. self._upsert(
"callbacks", name, config,
Args: require_absent=True,
name: Callback name (must be unique). in_memory_target=settings.callbacks,
config: Callback configuration. verb="added",
)
Raises:
ValueError: If callback already exists.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if callback already exists
if "callbacks" in data and name in data["callbacks"]:
raise ValueError(f"Callback '{name}' already exists")
# Add callback
if "callbacks" not in data:
data["callbacks"] = {}
data["callbacks"][name] = config.model_dump(exclude_none=True)
# Write YAML
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.callbacks[name] = config
logger.info(f"Callback '{name}' added to config")
def update_callback(self, name: str, config: CallbackConfig) -> None: def update_callback(self, name: str, config: CallbackConfig) -> None:
"""Update an existing callback. self._upsert(
"callbacks", name, config,
Args: require_present=True,
name: Callback name. in_memory_target=settings.callbacks,
config: New callback configuration. verb="updated",
)
Raises:
ValueError: If callback does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if callback exists
if "callbacks" not in data or name not in data["callbacks"]:
raise ValueError(f"Callback '{name}' does not exist")
# Update callback
data["callbacks"][name] = config.model_dump(exclude_none=True)
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.callbacks[name] = config
logger.info(f"Callback '{name}' updated in config")
def delete_callback(self, name: str) -> None: def delete_callback(self, name: str) -> None:
"""Delete a callback from config. self._delete("callbacks", name, in_memory_target=settings.callbacks)
Args: # --- Media folders -------------------------------------------------
name: Callback name.
Raises:
ValueError: If callback does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if callback exists
if "callbacks" not in data or name not in data["callbacks"]:
raise ValueError(f"Callback '{name}' does not exist")
# Delete callback
del data["callbacks"][name]
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if name in settings.callbacks:
del settings.callbacks[name]
logger.info(f"Callback '{name}' deleted from config")
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None: def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
"""Add a new media folder to config. self._upsert(
"media_folders", folder_id, config,
Args: require_absent=True,
folder_id: Folder ID (must be unique). in_memory_target=settings.media_folders,
config: Media folder configuration. verb="added",
)
Raises:
ValueError: If folder already exists.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if folder already exists
if "media_folders" in data and folder_id in data["media_folders"]:
raise ValueError(f"Media folder '{folder_id}' already exists")
# Add folder
if "media_folders" not in data:
data["media_folders"] = {}
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
# Write YAML
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.media_folders[folder_id] = config
logger.info(f"Media folder '{folder_id}' added to config")
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None: def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
"""Update an existing media folder. self._upsert(
"media_folders", folder_id, config,
Args: require_present=True,
folder_id: Folder ID. in_memory_target=settings.media_folders,
config: New media folder configuration. verb="updated",
)
Raises:
ValueError: If folder does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if folder exists
if "media_folders" not in data or folder_id not in data["media_folders"]:
raise ValueError(f"Media folder '{folder_id}' does not exist")
# Update folder
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.media_folders[folder_id] = config
logger.info(f"Media folder '{folder_id}' updated in config")
def delete_media_folder(self, folder_id: str) -> None: def delete_media_folder(self, folder_id: str) -> None:
"""Delete a media folder from config. self._delete("media_folders", folder_id, in_memory_target=settings.media_folders)
Args: # --- Links ---------------------------------------------------------
folder_id: Folder ID.
Raises:
ValueError: If folder does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if folder exists
if "media_folders" not in data or folder_id not in data["media_folders"]:
raise ValueError(f"Media folder '{folder_id}' does not exist")
# Delete folder
del data["media_folders"][folder_id]
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if folder_id in settings.media_folders:
del settings.media_folders[folder_id]
logger.info(f"Media folder '{folder_id}' deleted from config")
def add_link(self, name: str, config: LinkConfig) -> None: def add_link(self, name: str, config: LinkConfig) -> None:
"""Add a new link to config.""" self._upsert(
with self._lock: "links", name, config,
if not self._config_path.exists(): require_absent=True,
data = {} in_memory_target=settings.links,
else: verb="added",
with open(self._config_path, "r", encoding="utf-8") as f: )
data = yaml.safe_load(f) or {}
if "links" in data and name in data["links"]:
raise ValueError(f"Link '{name}' already exists")
if "links" not in data:
data["links"] = {}
data["links"][name] = config.model_dump(exclude_none=True)
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
settings.links[name] = config
logger.info(f"Link '{name}' added to config")
def update_link(self, name: str, config: LinkConfig) -> None: def update_link(self, name: str, config: LinkConfig) -> None:
"""Update an existing link.""" self._upsert(
with self._lock: "links", name, config,
if not self._config_path.exists(): require_present=True,
raise ValueError(f"Config file not found: {self._config_path}") in_memory_target=settings.links,
verb="updated",
with open(self._config_path, "r", encoding="utf-8") as f: )
data = yaml.safe_load(f) or {}
if "links" not in data or name not in data["links"]:
raise ValueError(f"Link '{name}' does not exist")
data["links"][name] = config.model_dump(exclude_none=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
settings.links[name] = config
logger.info(f"Link '{name}' updated in config")
def delete_link(self, name: str) -> None: def delete_link(self, name: str) -> None:
"""Delete a link from config.""" self._delete("links", name, in_memory_target=settings.links)
# --- Top-level settings --------------------------------------------
def set_setting(self, key: str, value: Any) -> None:
"""Set a top-level config setting and persist to YAML."""
with self._lock: with self._lock:
if not self._config_path.exists(): data = self._load()
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if "links" not in data or name not in data["links"]:
raise ValueError(f"Link '{name}' does not exist")
del data["links"][name]
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
if name in settings.links:
del settings.links[name]
logger.info(f"Link '{name}' deleted from config")
def set_setting(self, key: str, value) -> None:
"""Set a top-level config setting and persist to YAML.
Args:
key: Setting name (e.g., "visualizer_device").
value: Setting value (None removes the key).
"""
with self._lock:
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if value is None: if value is None:
data.pop(key, None) data.pop(key, None)
else: else:
data[key] = value data[key] = value
self._save(data)
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if hasattr(settings, key): if hasattr(settings, key):
setattr(settings, key, value) setattr(settings, key, value)
logger.info("Setting '%s' updated to: %s", key, value) logger.info("Setting '%s' updated to: %s", key, value)
# Global config manager instance
config_manager = ConfigManager() config_manager = ConfigManager()
+356 -78
View File
@@ -15,13 +15,14 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import __version__ from . import __version__
from .auth import get_token_label, token_label_var from .auth import get_token_label, request_id_var, token_label_var
from .config import generate_default_config, get_config_dir, settings from .config import generate_default_config, get_config_dir, settings
from .routes import ( from .routes import (
audio_router, audio_router,
browser_router, browser_router,
callbacks_router, callbacks_router,
display_router, display_router,
foreground_router,
health_router, health_router,
links_router, links_router,
media_router, media_router,
@@ -32,10 +33,34 @@ from .services.websocket_manager import ws_manager
class TokenLabelFilter(logging.Filter): class TokenLabelFilter(logging.Filter):
"""Add token label to log records.""" """Add token label + request_id to log records."""
def filter(self, record): def filter(self, record):
record.token_label = token_label_var.get("unknown") record.token_label = token_label_var.get("unknown")
record.request_id = request_id_var.get("-")
return True
class _StripTokenQueryFilter(logging.Filter):
"""Strip `token=...` from query strings before they hit the access log.
uvicorn's default access log format includes the full request line, so
`/api/media/artwork?token=SECRET` would otherwise be persisted verbatim
in stdout/journald/file sinks.
"""
import re as _re
_TOKEN_RE = _re.compile(r"([?&])token=[^&\s\"']+")
def filter(self, record): # type: ignore[override]
if isinstance(record.args, tuple):
record.args = tuple(
self._TOKEN_RE.sub(r"\1token=REDACTED", a) if isinstance(a, str) else a
for a in record.args
)
if isinstance(record.msg, str) and "token=" in record.msg:
record.msg = self._TOKEN_RE.sub(r"\1token=REDACTED", record.msg)
return True return True
@@ -48,82 +73,188 @@ def setup_logging():
logging.basicConfig( logging.basicConfig(
level=getattr(logging, settings.log_level.upper()), level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s", format=(
"%(asctime)s - %(name)s - [%(token_label)s] [%(request_id)s]"
" - %(levelname)s - %(message)s"
),
handlers=[handler], handlers=[handler],
) )
# Suppress noisy third-party loggers # Suppress noisy third-party loggers
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR) logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
# Make sure the uvicorn access log never persists tokens leaked into the
# query string (the artwork + WS endpoints accept `?token=` for browser
# compatibility — see verify_token_or_query).
strip_filter = _StripTokenQueryFilter()
for name in ("uvicorn.access", "uvicorn"):
logging.getLogger(name).addFilter(strip_filter)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan handler.""" """Application lifespan handler.
All long-lived resources started during startup are kept in local refs and
torn down in a `finally:` so a partial-startup failure cannot orphan tasks
or thread pools.
"""
import asyncio
setup_logging() setup_logging()
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 authentication status # Log authentication status — never log full or partial token material.
if settings.api_tokens: if settings.api_tokens:
for label, token in settings.api_tokens.items(): labels = ", ".join(settings.api_tokens.keys())
logger.info(f"API Token [{label}]: {token[:8]}...") logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
else: else:
logger.warning("No API tokens configured — authentication is DISABLED") logger.warning("No API tokens configured — authentication is DISABLED")
# Start WebSocket status monitor # Linux preflight: most MPRIS / PulseAudio failures are environmental
controller = get_media_controller() # (no DBUS_SESSION_BUS_ADDRESS, missing XDG_RUNTIME_DIR, systemd service
await ws_manager.start_status_monitor(controller.get_status) # started before logind). Surface that early so the failure mode is a
logger.info("WebSocket status monitor started") # warning at boot instead of silent "/api/media/status returns idle".
import os
# Start update checker
update_checker = None
if settings.update_check_enabled:
from .services.gitea_release_provider import GiteaReleaseProvider
from .services.update_checker import UpdateChecker
provider = GiteaReleaseProvider()
update_checker = UpdateChecker(provider, __version__)
await update_checker.start(settings.update_check_interval)
# Store globally so health endpoint can access cached result
app.state.update_checker = update_checker
# Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None
if settings.visualizer_enabled:
from .services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer(
num_bins=settings.visualizer_bins,
target_fps=settings.visualizer_fps,
device_name=settings.visualizer_device,
)
if analyzer.available:
await ws_manager.start_audio_monitor(analyzer)
logger.info("Audio visualizer available (capture on-demand)")
else:
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
yield
# Stop update checker
if update_checker is not None:
await update_checker.stop()
# Stop audio visualizer
await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running:
analyzer.stop()
# Stop WebSocket status monitor
await ws_manager.stop_status_monitor()
# Clean up platform-specific resources
import platform as _platform import platform as _platform
if _platform.system() == "Windows": if _platform.system() == "Linux":
from .services.windows_media import shutdown_executor missing = [
shutdown_executor() v for v in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR")
if not os.environ.get(v)
]
if missing:
logger.warning(
"Linux preflight: %s not set — MPRIS / PulseAudio may be unavailable."
" Under systemd, run `loginctl enable-linger <user>` and ensure the"
" service unit sets DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR.",
", ".join(missing),
)
if os.environ.get("WAYLAND_DISPLAY"):
logger.info(
"Wayland session detected — foreground-window probe is intentionally"
" disabled (Wayland hides window info from unprivileged clients)."
)
logger.info("Media Server shutting down") update_checker = None
cleanup_task: asyncio.Task | None = None
analyzer = None
status_monitor_started = False
try:
# Start WebSocket status monitor
controller = get_media_controller()
await ws_manager.start_status_monitor(controller.get_status)
status_monitor_started = True
logger.info("WebSocket status monitor started")
# Start update checker
if settings.update_check_enabled:
from .services.gitea_release_provider import GiteaReleaseProvider
from .services.update_checker import UpdateChecker
provider = GiteaReleaseProvider()
update_checker = UpdateChecker(provider, __version__)
await update_checker.start(settings.update_check_interval)
# Store globally so health endpoint can access cached result
app.state.update_checker = update_checker
# Schedule periodic thumbnail cache cleanup so the 500 MB cap is actually
# enforced. Runs once at startup and then hourly until shutdown.
from .services.thumbnail_service import ThumbnailService
async def _thumbnail_cleanup_loop() -> None:
while True:
try:
await asyncio.to_thread(ThumbnailService.cleanup_cache)
except Exception as e:
logger.warning("Thumbnail cache cleanup failed: %s", e)
try:
await asyncio.sleep(3600)
except asyncio.CancelledError:
break
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
# Register audio visualizer (capture starts on-demand when clients subscribe)
if settings.visualizer_enabled:
from .services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer(
num_bins=settings.visualizer_bins,
target_fps=settings.visualizer_fps,
device_name=settings.visualizer_device,
)
if analyzer.available:
await ws_manager.start_audio_monitor(analyzer)
logger.info("Audio visualizer available (capture on-demand)")
else:
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
yield
finally:
# Stop update checker
if update_checker is not None:
try:
await update_checker.stop()
except Exception:
logger.exception("Error stopping update checker")
# Cancel periodic thumbnail cleanup
if cleanup_task is not None:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Error awaiting thumbnail cleanup task")
# Stop audio visualizer
try:
await ws_manager.stop_audio_monitor()
except Exception:
logger.exception("Error stopping audio monitor")
if analyzer and analyzer.running:
try:
analyzer.stop()
except Exception:
logger.exception("Error stopping audio analyzer")
# Stop WebSocket status monitor
if status_monitor_started:
try:
await ws_manager.stop_status_monitor()
except Exception:
logger.exception("Error stopping status monitor")
# Shut down dedicated thread pools so pending scripts don't leak threads
try:
from .routes.callbacks import shutdown_callback_executor
from .routes.scripts import shutdown_script_executor
shutdown_script_executor()
shutdown_callback_executor()
except Exception:
logger.exception("Error shutting down script/callback executors")
# Flush audit log writer
try:
from .services.audit_log import shutdown_audit_log
shutdown_audit_log()
except Exception:
logger.exception("Error flushing audit log")
# Clean up platform-specific resources
import platform as _platform
if _platform.system() == "Windows":
try:
from .services.windows_media import shutdown_executor
shutdown_executor()
except Exception:
logger.exception("Error shutting down windows_media executor")
logger.info("Media Server shutting down")
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -138,42 +269,128 @@ def create_app() -> FastAPI:
# Compress responses > 1KB # Compress responses > 1KB
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests # CORS — restrict to same-origin by default; users that integrate the API
# Token auth is via Authorization header, not cookies, so credentials are not needed # from another origin (e.g. Home Assistant on a different host) can set
# cors_origins in config.yaml. Refuse "*" outright: combined with the
# admin endpoints this would let any origin in the universe run
# arbitrary shell. If users genuinely need every origin, they can list
# them explicitly.
if any(o.strip() == "*" for o in settings.cors_origins):
raise RuntimeError(
"cors_origins must not contain '*' — list exact origins instead. "
"This protects the script-execution endpoints from any-origin abuse."
)
cors_origins = settings.cors_origins or [
f"http://localhost:{settings.port}",
f"http://127.0.0.1:{settings.port}",
]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=cors_origins,
allow_credentials=False, allow_credentials=False,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"],
) )
# Add token logging middleware # Request correlation ID — accept upstream X-Request-ID if it's a sane
# ASCII id, otherwise mint a fresh UUID4. Emitted on the response so
# clients can quote it back in bug reports.
import re
import uuid as _uuid
_REQ_ID_RE = re.compile(r"^[A-Za-z0-9._\-]{1,128}$")
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
incoming = request.headers.get("x-request-id", "")
req_id = incoming if _REQ_ID_RE.match(incoming) else _uuid.uuid4().hex[:16]
request_id_var.set(req_id)
response = await call_next(request)
response.headers["X-Request-ID"] = req_id
return response
# Security headers — strict CSP for the bundled UI, disallow framing, hide referrer.
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault(
"Content-Security-Policy",
(
"default-src 'self'; "
"img-src 'self' data: blob: https://api.iconify.design; "
"connect-src 'self' https://api.iconify.design ws: wss:; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"worker-src 'self'; "
"manifest-src 'self'; "
"base-uri 'self'"
),
)
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
return response
# Add token logging middleware + auth-failure rate limit
from fastapi.responses import JSONResponse
from .services.rate_limit import check as ratelimit_check
from .services.rate_limit import get_peer
@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, set in context, and rate-limit failed auths."""
if not settings.api_tokens: if not settings.api_tokens:
token_label_var.set("anonymous") token_label_var.set("anonymous")
else: else:
token_label = "unknown" token_label = "unknown"
token_present = False
token_valid = False
# 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_present = True
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
token_valid = True
# 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_present = True
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_valid = True
token_label_var.set(token_label) token_label_var.set(token_label)
# Brute-force gate: a peer that produces a wrong/missing token gets
# 5 failures per minute before being throttled. Static-asset
# requests (GET /static/*, /, /sw.js) and the docs endpoint are
# exempt — they're served unauthenticated by design.
if token_present and not token_valid:
path = request.url.path
if not (
path == "/" or path == "/sw.js"
or path.startswith("/static/")
or path.startswith("/docs") or path.startswith("/openapi")
or path.startswith("/redoc")
):
allowed, retry_after = ratelimit_check("auth", get_peer(request))
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "Too many authentication failures"},
headers={"Retry-After": str(int(retry_after or 60))},
)
response = await call_next(request) response = await call_next(request)
return response return response
@@ -182,6 +399,7 @@ def create_app() -> FastAPI:
app.include_router(browser_router) app.include_router(browser_router)
app.include_router(callbacks_router) app.include_router(callbacks_router)
app.include_router(display_router) app.include_router(display_router)
app.include_router(foreground_router)
app.include_router(health_router) app.include_router(health_router)
app.include_router(links_router) app.include_router(links_router)
app.include_router(media_router) app.include_router(media_router)
@@ -205,6 +423,11 @@ def create_app() -> FastAPI:
async def serve_ui(): async def serve_ui():
"""Serve the Web UI.""" """Serve the Web UI."""
return FileResponse(static_dir / "index.html") return FileResponse(static_dir / "index.html")
else:
logging.getLogger(__name__).warning(
"static_dir not found at %s — Web UI disabled (API only)",
static_dir,
)
return app return app
@@ -247,36 +470,94 @@ 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("Authentication is disabled by default. Add api_tokens to enable it.") print("A random API token was generated under api_tokens.default.")
print("Run `python -m media_server.main --show-token` to view 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()}")
if settings.api_tokens: if settings.api_tokens:
print("\nAPI Tokens:") print("\nAPI Tokens:")
for label, token in settings.api_tokens.items(): for label, spec in settings.api_tokens.items():
print(f" {label:20} {token}") scope_str = ",".join(spec.scopes)
print(f" {label:20} {spec.token} [scopes: {scope_str}]")
else: else:
print("\nAuthentication is DISABLED (no tokens configured)") print("\nAuthentication is DISABLED (no tokens configured)")
return return
# Stderr is invisible when launched via wscript / pythonw (Start Menu shortcut,
# autostart). Mirror pre-uvicorn failures to a file in the config dir so the
# next silent boot failure is diagnosable.
def _fatal(msg: str, exit_code: int = 1) -> None:
print(msg, file=sys.stderr)
try:
log_path = get_config_dir() / "startup-errors.log"
from datetime import datetime
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
except OSError:
pass
sys.exit(exit_code)
# First-run bootstrap: if no config has ever been written, generate one
# with a random token instead of starting in the insecure "no-auth" mode.
config_path = get_config_dir() / "config.yaml"
if not config_path.exists() and not settings.api_tokens:
try:
generate_default_config(config_path)
_fatal(
f"\nFirst run: generated default config at {config_path}.\n"
"Run --show-token to retrieve the API token, then restart.",
exit_code=0,
)
except OSError as e:
print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr)
# Refuse to bind a non-loopback address with no tokens, unless explicitly opted in.
non_loopback = args.host not in ("127.0.0.1", "localhost", "::1")
if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth:
_fatal(
"ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n"
"Either set api_tokens in config.yaml, bind to 127.0.0.1,"
" or set allow_lan_without_auth: true in config.yaml to override."
)
# Check if port is available before starting # Check if port is available before starting
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try: try:
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port)) sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
except OSError: except OSError:
print( _fatal(
f"ERROR: Port {args.port} is already in use. " f"ERROR: Port {args.port} is already in use. "
f"Another instance of Media Server may be running.\n" f"Another instance of Media Server may be running.\n"
f"Stop the other process or use --port to pick a different port.", f"Stop the other process or use --port to pick a different port."
file=sys.stderr,
) )
sys.exit(1)
from .tray import PYSTRAY_AVAILABLE, TrayManager from .tray import PYSTRAY_AVAILABLE, TrayManager
use_tray = PYSTRAY_AVAILABLE and not args.no_tray use_tray = PYSTRAY_AVAILABLE and not args.no_tray
# Validate TLS pair consistency before either path so we don't fail late.
if bool(settings.ssl_certfile) ^ bool(settings.ssl_keyfile):
_fatal(
"ERROR: ssl_certfile and ssl_keyfile must both be set, or both unset."
)
def _uvicorn_kwargs() -> dict:
kw: dict = {
"host": args.host,
"port": args.port,
"log_level": settings.log_level.lower(),
"proxy_headers": settings.proxy_headers,
"forwarded_allow_ips": settings.forwarded_allow_ips,
}
if settings.ssl_certfile and settings.ssl_keyfile:
kw["ssl_certfile"] = settings.ssl_certfile
kw["ssl_keyfile"] = settings.ssl_keyfile
if settings.ssl_keyfile_password:
kw["ssl_keyfile_password"] = settings.ssl_keyfile_password
return kw
if use_tray: if use_tray:
import asyncio import asyncio
import threading import threading
@@ -284,9 +565,7 @@ def main():
# Run uvicorn in a background thread so tray owns the main thread message loop # Run uvicorn in a background thread so tray owns the main thread message loop
uv_config = uvicorn.Config( uv_config = uvicorn.Config(
"media_server.main:app", "media_server.main:app",
host=args.host, **_uvicorn_kwargs(),
port=args.port,
log_level=settings.log_level.lower(),
) )
server = uvicorn.Server(uv_config) server = uvicorn.Server(uv_config)
@@ -324,9 +603,8 @@ def main():
else: else:
uvicorn.run( uvicorn.run(
"media_server.main:app", "media_server.main:app",
host=args.host,
port=args.port,
reload=False, reload=False,
**_uvicorn_kwargs(),
) )
+2
View File
@@ -4,6 +4,7 @@ from .audio import router as audio_router
from .browser import router as browser_router from .browser import router as browser_router
from .callbacks import router as callbacks_router from .callbacks import router as callbacks_router
from .display import router as display_router from .display import router as display_router
from .foreground import router as foreground_router
from .health import router as health_router from .health import router as health_router
from .links import router as links_router from .links import router as links_router
from .media import router as media_router from .media import router as media_router
@@ -14,6 +15,7 @@ __all__ = [
"browser_router", "browser_router",
"callbacks_router", "callbacks_router",
"display_router", "display_router",
"foreground_router",
"health_router", "health_router",
"links_router", "links_router",
"media_router", "media_router",
+109 -81
View File
@@ -23,14 +23,33 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"]) router = APIRouter(prefix="/api/browser", tags=["browser"])
# Strong refs to background tasks so they don't get garbage-collected mid-flight.
_background_tasks: set[asyncio.Task] = set()
def _spawn_background(coro) -> asyncio.Task:
"""Schedule a background coroutine and keep a strong ref to its Task."""
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task
def _require_folder_management() -> None: def _require_folder_management() -> None:
"""Raise 403 if media folder management is disabled in config.""" """Raise 403 if media folder management is disabled OR caller lacks admin scope."""
if not settings.media_folders_management: if not settings.media_folders_management:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.", detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
) )
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=403,
detail=f"Token '{label}' lacks required scope: admin",
)
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None: async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
@@ -38,16 +57,23 @@ async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -
Fires as a background task so the HTTP response returns immediately. Fires as a background task so the HTTP response returns immediately.
""" """
status = None
try: try:
interval = 0.3 interval = 0.3
elapsed = 0.0 elapsed = 0.0
while elapsed < max_wait: while elapsed < max_wait:
await asyncio.sleep(interval) await asyncio.sleep(interval)
elapsed += interval elapsed += interval
status = await controller.get_status() try:
status = await controller.get_status()
except Exception as poll_err: # noqa: BLE001 — broadcast is best-effort
logger.debug("get_status during broadcast poll failed: %s", poll_err)
continue
if status.state in ("playing", "paused"): if status.state in ("playing", "paused"):
break break
if status is None:
return
status_dict = status.model_dump() status_dict = status.model_dump()
await ws_manager.broadcast({"type": "status", "data": status_dict}) await ws_manager.broadcast({"type": "status", "data": status_dict})
logger.info(f"Broadcasted status update after opening: {label}") logger.info(f"Broadcasted status update after opening: {label}")
@@ -74,9 +100,14 @@ class FolderUpdateRequest(BaseModel):
class PlayRequest(BaseModel): class PlayRequest(BaseModel):
"""Request model for playing a media file.""" """Request model for playing a media file.
path: str = Field(..., description="Full path to the media file") Both ``folder_id`` and ``path`` are required so the server can validate
the file lives inside a configured media folder.
"""
folder_id: str = Field(..., description="Media folder ID")
path: str = Field(..., description="Path relative to folder root")
class PlayFolderRequest(BaseModel): class PlayFolderRequest(BaseModel):
@@ -128,8 +159,10 @@ async def create_folder(
""" """
_require_folder_management() _require_folder_management()
try: try:
# Validate folder_id format (alphanumeric and underscore only) # Validate folder_id format (alphanumeric and underscore only).
if not request.folder_id.replace("_", "").isalnum(): # Same constraint is enforced when validating paths so traversal can't
# be smuggled through the ID itself.
if not request.folder_id or not request.folder_id.replace("_", "").isalnum():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Folder ID must contain only alphanumeric characters and underscores", detail="Folder ID must contain only alphanumeric characters and underscores",
@@ -277,13 +310,15 @@ async def browse(
# URL decode the path # URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
# Browse directory # Browse directory in a thread — iterdir() + stat() can block on
result = BrowserService.browse_directory( # network shares for many seconds; never run on the event loop.
folder_id=folder_id, result = await asyncio.to_thread(
path=decoded_path, BrowserService.browse_directory,
offset=offset, folder_id,
limit=limit, decoded_path,
nocache=nocache, offset,
limit,
nocache,
) )
return result return result
@@ -307,41 +342,40 @@ async def browse(
# Metadata Endpoint # Metadata Endpoint
@router.get("/metadata") @router.get("/metadata")
async def get_metadata( async def get_metadata(
path: str = Query(..., description="Full path to media file (URL-encoded)"), folder_id: str = Query(..., description="Media folder ID"),
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
_: str = Depends(verify_token), _: str = Depends(verify_token),
): ):
"""Get metadata for a media file. """Get metadata for a media file inside a configured media folder.
Args: Args:
path: Full path to the media file (URL-encoded). folder_id: ID of the media folder.
path: Path relative to folder root (URL-encoded).
Returns: Returns:
Media file metadata. Media file metadata.
Raises:
HTTPException: If file not found or metadata extraction fails.
""" """
try: try:
# URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
file_path = Path(decoded_path) file_path = BrowserService.validate_path(folder_id, decoded_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a file") raise HTTPException(status_code=400, detail="Path is not a file")
if not BrowserService.is_media_file(file_path):
raise HTTPException(status_code=400, detail="File is not a media file")
# Extract metadata in executor (blocking operation) loop = asyncio.get_running_loop()
loop = asyncio.get_event_loop()
metadata = await loop.run_in_executor( metadata = await loop.run_in_executor(
None, None,
MetadataService.extract_metadata, MetadataService.extract_metadata,
file_path, file_path,
) )
return metadata return metadata
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -352,59 +386,47 @@ async def get_metadata(
# Thumbnail Endpoint # Thumbnail Endpoint
@router.get("/thumbnail") @router.get("/thumbnail")
async def get_thumbnail( async def get_thumbnail(
path: str = Query(..., description="Full path to media file (URL-encoded)"), folder_id: str = Query(..., description="Media folder ID"),
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'), size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
_: str = Depends(verify_token), _: str = Depends(verify_token),
): ):
"""Get thumbnail for a media file. """Get thumbnail for a media file inside a configured media folder."""
Args:
path: Full path to the media file (URL-encoded).
size: Thumbnail size ("small" or "medium").
Returns:
JPEG image bytes.
Raises:
HTTPException: If file not found or thumbnail generation fails.
"""
try: try:
# URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
file_path = Path(decoded_path) file_path = BrowserService.validate_path(folder_id, decoded_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a file") raise HTTPException(status_code=400, detail="Path is not a file")
if not BrowserService.is_media_file(file_path):
raise HTTPException(status_code=400, detail="File is not a media file")
# Validate size
if size not in ("small", "medium"): if size not in ("small", "medium"):
size = "medium" size = "medium"
# Get thumbnail
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size) thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
if thumbnail_data is None: if thumbnail_data is None:
return Response(status_code=204) return Response(status_code=204)
# Calculate ETag (hash of path + mtime)
import hashlib import hashlib
stat = file_path.stat() stat = file_path.stat()
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode() etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
etag = hashlib.md5(etag_data).hexdigest() etag = hashlib.md5(etag_data).hexdigest()
# Return image with caching headers
return Response( return Response(
content=thumbnail_data, content=thumbnail_data,
media_type="image/jpeg", media_type="image/jpeg",
headers={ headers={
"ETag": f'"{etag}"', "ETag": f'"{etag}"',
"Cache-Control": "public, max-age=86400", # 24 hours "Cache-Control": "public, max-age=86400",
}, },
) )
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -420,44 +442,37 @@ async def play_file(
): ):
"""Open a media file with the default system player. """Open a media file with the default system player.
Args: Requires both ``folder_id`` and a folder-relative ``path``; the resolved
request: Play request with file path. file must live inside the configured media folder and be a recognized
media file. This prevents arbitrary OS-handler invocation (e.g.,
Returns: ``os.startfile`` on Windows ``.lnk``/UNC paths).
Success message.
Raises:
HTTPException: If file not found or playback fails.
""" """
try: try:
file_path = Path(request.path) decoded_path = unquote(request.path)
file_path = BrowserService.validate_path(request.folder_id, decoded_path)
# Validate file exists
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a file") raise HTTPException(status_code=400, detail="Path is not a file")
# Validate file is a media file
if not BrowserService.is_media_file(file_path): if not BrowserService.is_media_file(file_path):
raise HTTPException(status_code=400, detail="File is not a media file") raise HTTPException(status_code=400, detail="File is not a media file")
# Get media controller and open file
controller = get_media_controller() controller = get_media_controller()
success = await controller.open_file(str(file_path)) success = await controller.open_file(str(file_path))
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to open file") raise HTTPException(status_code=500, detail="Failed to open file")
# Poll until player registers with media session API (up to 2s) _spawn_background(_broadcast_after_open(controller, file_path.name))
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
return { return {
"success": True, "success": True,
"message": f"Playing {file_path.name}", "message": f"Playing {file_path.name}",
} }
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -489,26 +504,38 @@ async def play_folder(
if not full_path.is_dir(): if not full_path.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory") raise HTTPException(status_code=400, detail="Path is not a directory")
# Collect all media files sorted by name def _scan(directory: Path) -> list[Path]:
media_files = sorted( return sorted(
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)], (
key=lambda f: f.name.lower(), f for f in directory.iterdir()
) if f.is_file() and BrowserService.is_media_file(f)
),
key=lambda f: f.name.lower(),
)
media_files = await asyncio.to_thread(_scan, full_path)
if not media_files: if not media_files:
raise HTTPException(status_code=404, detail="No media files found in this folder") raise HTTPException(status_code=404, detail="No media files found in this folder")
# Generate M3U playlist with absolute paths and EXTINF entries # Generate M3U playlist with absolute paths and EXTINF entries.
# Written to local temp dir to avoid extra SMB file handle on network shares # Use NamedTemporaryFile to get a fresh per-call path — prevents
# Uses utf-8-sig (BOM) so players detect encoding properly # symlink-clobber races between concurrent /play-folder requests
# and any local user pre-creating a fixed temp filename.
lines = ["#EXTM3U"] lines = ["#EXTM3U"]
for f in media_files: for f in media_files:
lines.append(f"#EXTINF:-1,{f.stem}") lines.append(f"#EXTINF:-1,{f.stem}")
lines.append(str(f)) lines.append(str(f))
m3u_content = "\r\n".join(lines) + "\r\n" m3u_content = ("\r\n".join(lines) + "\r\n").encode("utf-8-sig")
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u" with tempfile.NamedTemporaryFile(
playlist_path.write_text(m3u_content, encoding="utf-8-sig") mode="wb",
prefix=".media_server_playlist_",
suffix=".m3u",
delete=False,
) as f:
f.write(m3u_content)
playlist_path = Path(f.name)
# Open playlist with default player # Open playlist with default player
controller = get_media_controller() controller = get_media_controller()
@@ -517,8 +544,9 @@ async def play_folder(
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to open playlist") raise HTTPException(status_code=500, detail="Failed to open playlist")
# Poll until player registers with media session API (up to 2s) _spawn_background(
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")) _broadcast_after_open(controller, f"playlist ({len(media_files)} files)")
)
return { return {
"success": True, "success": True,
+68 -5
View File
@@ -3,16 +3,19 @@
import asyncio import asyncio
import logging import logging
import subprocess import subprocess
import sys
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..auth import verify_token from ..auth import verify_token
from ..config import CallbackConfig, settings from ..config import CallbackConfig, settings
from ..config_manager import config_manager from ..config_manager import config_manager
from ..services.rate_limit import check as ratelimit_check
from ..services.rate_limit import get_peer
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"]) router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,6 +24,31 @@ logger = logging.getLogger(__name__)
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback") _callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
def shutdown_callback_executor() -> None:
"""Shut down the callback executor cleanly on application teardown."""
_callback_executor.shutdown(wait=False, cancel_futures=True)
def _require_callbacks_management() -> None:
"""Authorise a callbacks-CRUD operation. Operator flag + per-token admin scope."""
if not settings.callbacks_management:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Callbacks management is disabled. Set callbacks_management: true"
" in config.yaml to enable."
),
)
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: admin",
)
class CallbackInfo(BaseModel): class CallbackInfo(BaseModel):
"""Information about a configured callback.""" """Information about a configured callback."""
@@ -105,6 +133,7 @@ async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
@router.post("/execute/{callback_name}") @router.post("/execute/{callback_name}")
async def execute_callback( async def execute_callback(
callback_name: str, callback_name: str,
http_request: Request,
_: str = Depends(verify_token), _: str = Depends(verify_token),
) -> CallbackExecuteResponse: ) -> CallbackExecuteResponse:
"""Execute a callback for debugging purposes. """Execute a callback for debugging purposes.
@@ -115,6 +144,16 @@ async def execute_callback(
Returns: Returns:
Execution result including stdout, stderr, and exit code Execution result including stdout, stderr, and exit code
""" """
# Rate-limit callback execution per peer (10/min) — callbacks also run
# subprocesses and need the same protection as scripts.
allowed, retry_after = ratelimit_check("execute", get_peer(http_request))
if not allowed:
raise HTTPException(
status_code=429,
detail="Too many callback executions, slow down",
headers={"Retry-After": str(int(retry_after or 60))},
)
# Validate callback name # Validate callback name
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
@@ -129,9 +168,11 @@ async def execute_callback(
logger.info(f"Executing callback for debugging: {callback_name}") logger.info(f"Executing callback for debugging: {callback_name}")
from ..services.audit_log import record_script_execution
try: try:
# Execute in dedicated thread pool to not block the default executor # Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
_callback_executor, _callback_executor,
lambda: _run_callback( lambda: _run_callback(
@@ -142,6 +183,15 @@ async def execute_callback(
), ),
) )
record_script_execution(
kind="callback",
name=callback_name,
exit_code=result["exit_code"],
duration=result.get("execution_time"),
stdout=result.get("stdout"),
stderr=result.get("stderr"),
)
return CallbackExecuteResponse( return CallbackExecuteResponse(
success=result["exit_code"] == 0, success=result["exit_code"] == 0,
callback=callback_name, callback=callback_name,
@@ -153,6 +203,13 @@ async def execute_callback(
except Exception as e: except Exception as e:
logger.error(f"Callback execution error: {e}") logger.error(f"Callback execution error: {e}")
record_script_execution(
kind="callback",
name=callback_name,
exit_code=None,
duration=None,
error=str(e),
)
return CallbackExecuteResponse( return CallbackExecuteResponse(
success=False, success=False,
callback=callback_name, callback=callback_name,
@@ -178,6 +235,11 @@ def _run_callback(
Dict with exit_code, stdout, stderr, execution_time Dict with exit_code, stdout, stderr, execution_time
""" """
start_time = time.time() start_time = time.time()
popen_kwargs: dict[str, Any] = {}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
try: try:
result = subprocess.run( result = subprocess.run(
command, command,
@@ -186,6 +248,7 @@ def _run_callback(
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
**popen_kwargs,
) )
execution_time = time.time() - start_time execution_time = time.time() - start_time
return { return {
@@ -230,7 +293,7 @@ async def create_callback(
Raises: Raises:
HTTPException: If callback already exists or name is invalid. HTTPException: If callback already exists or name is invalid.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback already exists # Check if callback already exists
@@ -278,7 +341,7 @@ async def update_callback(
Raises: Raises:
HTTPException: If callback does not exist. HTTPException: If callback does not exist.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback exists # Check if callback exists
@@ -324,7 +387,7 @@ async def delete_callback(
Raises: Raises:
HTTPException: If callback does not exist. HTTPException: If callback does not exist.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback exists # Check if callback exists
+80 -6
View File
@@ -1,5 +1,6 @@
"""Display brightness and power control API endpoints.""" """Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
import asyncio
import logging import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -9,6 +10,10 @@ from ..auth import verify_token
from ..services.display_service import ( from ..services.display_service import (
list_monitors, list_monitors,
set_brightness, set_brightness,
set_color_preset,
set_contrast,
set_input_source,
set_picture_mode,
set_power, set_power,
) )
@@ -25,12 +30,37 @@ class PowerRequest(BaseModel):
on: bool 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") @router.get("/monitors")
async def get_monitors( async def get_monitors(
refresh: bool = False, _: str = Depends(verify_token) refresh: bool = False,
rediscover: bool = False,
_: str = Depends(verify_token),
) -> list[dict]: ) -> list[dict]:
"""List all connected monitors with brightness and power info.""" """List all connected monitors with their reported DDC/CI capabilities."""
monitors = list_monitors(force_refresh=refresh) monitors = await asyncio.to_thread(
list_monitors, force_refresh=refresh, rediscover=rediscover
)
logger.debug("Found %d monitors", len(monitors)) logger.debug("Found %d monitors", len(monitors))
return [m.to_dict() for m in monitors] return [m.to_dict() for m in monitors]
@@ -40,7 +70,7 @@ async def set_monitor_brightness(
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token) monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
) -> dict: ) -> dict:
"""Set brightness for a specific monitor.""" """Set brightness for a specific monitor."""
success = set_brightness(monitor_id, request.brightness) success = await asyncio.to_thread(set_brightness, monitor_id, request.brightness)
if success: if success:
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness) logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
return {"success": success} return {"success": success}
@@ -52,7 +82,51 @@ async def set_monitor_power(
) -> dict: ) -> dict:
"""Turn a monitor on or off.""" """Turn a monitor on or off."""
action = "on" if request.on else "off" action = "on" if request.on else "off"
success = set_power(monitor_id, request.on) success = await asyncio.to_thread(set_power, monitor_id, request.on)
if success: if success:
logger.info("Set monitor %d power %s", monitor_id, action) logger.info("Set monitor %d power %s", monitor_id, action)
return {"success": success} 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}
+26
View File
@@ -0,0 +1,26 @@
"""Foreground (topmost) window/process API."""
import asyncio
import logging
from fastapi import APIRouter, Depends
from ..auth import verify_token
from ..services.foreground_service import get_foreground_info
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/foreground", tags=["foreground"])
@router.get("")
async def get_foreground(
refresh: bool = False, _: str = Depends(verify_token)
) -> dict:
"""Return metadata about the foreground window and owning process.
The probe is cached for ~500ms server-side; pass ``?refresh=1`` to bypass
the cache for one-shot queries.
"""
info = await asyncio.to_thread(get_foreground_info, refresh)
return info.to_dict()
+58 -13
View File
@@ -3,9 +3,10 @@
import logging import logging
import re import re
from typing import Any from typing import Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from ..auth import verify_token from ..auth import verify_token
from ..config import LinkConfig, settings from ..config import LinkConfig, settings
@@ -15,6 +16,44 @@ from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/links", tags=["links"]) router = APIRouter(prefix="/api/links", tags=["links"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Only allow MDI iconify slugs and safe `http(s)`-ish URLs through the API.
_MDI_ICON_RE = re.compile(r"^mdi:[a-z0-9][a-z0-9-]{0,63}$")
_ALLOWED_URL_SCHEMES = {"http", "https"}
def _validate_url(url: str) -> str:
"""Ensure the URL is well-formed http(s) — no ``javascript:`` etc."""
parsed = urlparse(url)
if parsed.scheme.lower() not in _ALLOWED_URL_SCHEMES:
raise ValueError("URL must start with http:// or https://")
if not parsed.netloc:
raise ValueError("URL must include a host")
return url
def _validate_icon(icon: str) -> str:
"""Restrict icon names to safe Material Design Icons slugs."""
if not _MDI_ICON_RE.match(icon):
raise ValueError("Icon must be of the form 'mdi:<lowercase-slug>'")
return icon
def _require_links_management() -> None:
"""Authorise a links-CRUD operation. Operator flag + per-token admin scope."""
if not settings.links_management:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Links management is disabled. Set links_management: true in config.yaml to enable.",
)
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: admin",
)
class LinkInfo(BaseModel): class LinkInfo(BaseModel):
"""Information about a configured link.""" """Information about a configured link."""
@@ -29,22 +68,25 @@ class LinkInfo(BaseModel):
class LinkCreateRequest(BaseModel): class LinkCreateRequest(BaseModel):
"""Request model for creating or updating a link.""" """Request model for creating or updating a link."""
url: str = Field(..., description="URL to open", min_length=1) url: str = Field(..., description="URL to open", min_length=1, max_length=2048)
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')") icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
label: str = Field(default="", description="Tooltip text") label: str = Field(default="", description="Tooltip text", max_length=128)
description: str = Field(default="", description="Optional description") description: str = Field(default="", description="Optional description", max_length=512)
@field_validator("url")
@classmethod
def _check_url(cls, v: str) -> str:
return _validate_url(v)
@field_validator("icon")
@classmethod
def _check_icon(cls, v: str) -> str:
return _validate_icon(v)
def _validate_link_name(name: str) -> None: def _validate_link_name(name: str) -> None:
"""Validate link name. """Validate link name."""
if not re.match(r"^[a-zA-Z0-9_]+$", name):
Args:
name: Link name to validate.
Raises:
HTTPException: If name is invalid.
"""
if not re.match(r'^[a-zA-Z0-9_]+$', name):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must contain only letters, numbers, and underscores", detail="Link name must contain only letters, numbers, and underscores",
@@ -90,6 +132,7 @@ async def create_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name in settings.links: if link_name in settings.links:
@@ -129,6 +172,7 @@ async def update_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name not in settings.links: if link_name not in settings.links:
@@ -166,6 +210,7 @@ async def delete_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name not in settings.links: if link_name not in settings.links:
+178 -30
View File
@@ -3,13 +3,22 @@
import asyncio import asyncio
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Request,
WebSocket,
WebSocketDisconnect,
status,
)
from fastapi.responses import Response from fastapi.responses import Response
from ..auth import verify_token, verify_token_or_query from ..auth import verify_token, verify_token_or_query
from ..config import settings from ..config import settings
from ..models import MediaStatus, SeekRequest, VolumeRequest from ..models import MediaStatus, SeekRequest, VolumeRequest
from ..services import get_current_album_art, get_media_controller from ..services import get_current_album_art_async, get_media_controller
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,19 +26,28 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["media"]) router = APIRouter(prefix="/api/media", tags=["media"])
# Strong refs to background tasks so the asyncio GC can't drop them before
# they run. Mirrors the pattern used in routes/browser.py.
_background_callback_tasks: set[asyncio.Task] = set()
def _run_callback(callback_name: str) -> None: def _run_callback(callback_name: str) -> None:
"""Fire-and-forget a callback if configured. Failures are logged but don't block.""" """Fire-and-forget a callback if configured. Failures are logged but don't block."""
if not settings.callbacks or callback_name not in settings.callbacks: if not settings.callbacks or callback_name not in settings.callbacks:
return return
async def _execute(): async def _execute():
# Use the dedicated callback executor (not the default loop pool) so a
# misbehaving callback can't starve the rest of the app's sync tasks.
from ..services.audit_log import record_script_execution
from .callbacks import _callback_executor
from .scripts import _run_script from .scripts import _run_script
try: try:
callback = settings.callbacks[callback_name] callback = settings.callbacks[callback_name]
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, _callback_executor,
lambda: _run_script( lambda: _run_script(
command=callback.command, command=callback.command,
timeout=callback.timeout, timeout=callback.timeout,
@@ -37,6 +55,14 @@ def _run_callback(callback_name: str) -> None:
working_dir=callback.working_dir, working_dir=callback.working_dir,
), ),
) )
record_script_execution(
kind="event-callback",
name=callback_name,
exit_code=result["exit_code"],
duration=result.get("execution_time"),
stdout=result.get("stdout"),
stderr=result.get("stderr"),
)
if result["exit_code"] != 0: if result["exit_code"] != 0:
logger.warning( logger.warning(
"Callback %s failed with exit code %s: %s", "Callback %s failed with exit code %s: %s",
@@ -46,8 +72,18 @@ def _run_callback(callback_name: str) -> None:
) )
except Exception as e: except Exception as e:
logger.error("Callback %s error: %s", callback_name, e) logger.error("Callback %s error: %s", callback_name, e)
from ..services.audit_log import record_script_execution as _rec
_rec(
kind="event-callback",
name=callback_name,
exit_code=None,
duration=None,
error=str(e),
)
asyncio.create_task(_execute()) task = asyncio.create_task(_execute())
_background_callback_tasks.add(task)
task.add_done_callback(_background_callback_tasks.discard)
@router.get("/status", response_model=MediaStatus) @router.get("/status", response_model=MediaStatus)
@@ -242,41 +278,91 @@ async def toggle(_: str = Depends(verify_token)) -> dict:
@router.get("/artwork") @router.get("/artwork")
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response: async def get_artwork(
request: Request,
_: str = Depends(verify_token_or_query),
) -> Response:
"""Get the current album artwork. """Get the current album artwork.
Returns: Returns the bytes with a content-derived ETag so the browser can serve a
The album art image as PNG/JPEG 304 when the same track is re-requested.
""" """
art_bytes = get_current_album_art() art_bytes = await get_current_album_art_async()
if art_bytes is None: if art_bytes is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="No album artwork available", detail="No album artwork available",
) )
# Try to detect image type from magic bytes # Detect image type from magic bytes
content_type = "image/png" # Default
if art_bytes[:3] == b"\xff\xd8\xff": if art_bytes[:3] == b"\xff\xd8\xff":
content_type = "image/jpeg" content_type = "image/jpeg"
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n": elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
content_type = "image/png" content_type = "image/png"
elif art_bytes[:4] == b"RIFF" and art_bytes[8:12] == b"WEBP": elif art_bytes[:4] == b"RIFF" and len(art_bytes) > 12 and art_bytes[8:12] == b"WEBP":
content_type = "image/webp" content_type = "image/webp"
elif art_bytes[:2] == b"BM":
content_type = "image/bmp"
else:
content_type = "application/octet-stream"
return Response(content=art_bytes, media_type=content_type) # Content-derived ETag (blake2b-128 — non-crypto cache key, ruff S324-safe)
import hashlib
etag = '"' + hashlib.blake2b(art_bytes, digest_size=16).hexdigest() + '"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag})
return Response(
content=art_bytes,
media_type=content_type,
headers={
"ETag": etag,
"Cache-Control": "private, max-age=0, must-revalidate",
},
)
@router.get("/visualizer/status") @router.get("/visualizer/status")
async def visualizer_status(_: str = Depends(verify_token)) -> dict: async def visualizer_status(_: str = Depends(verify_token)) -> dict:
"""Check if audio visualizer is available and running.""" """Check if audio visualizer is available and running.
``available`` is True only when both numpy + soundcard import.
``unavailable_reason`` carries a short human-readable hint when False
or when a usable loopback device was not found — useful on Linux where
PulseAudio/PipeWire may not expose monitor sources to a headless
systemd-as-user service.
"""
import platform as _platform
from ..services.audio_analyzer import get_audio_analyzer from ..services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer() analyzer = get_audio_analyzer()
reason: str | None = None
if not analyzer.available:
reason = "soundcard or numpy is not installed"
elif getattr(analyzer, "_unavailable", False):
if _platform.system() == "Linux":
reason = (
"No loopback audio device found. On Linux this requires a "
"running PulseAudio/PipeWire session with monitor sources "
"(systemd user service: ensure DBUS_SESSION_BUS_ADDRESS + "
"XDG_RUNTIME_DIR are set)."
)
elif _platform.system() == "Darwin":
reason = (
"No loopback audio device found. macOS does not expose system "
"loopback by default — install BlackHole or Soundflower."
)
else:
reason = "No loopback audio device found"
return { return {
"available": analyzer.available, "available": analyzer.available,
"running": analyzer.running, "running": analyzer.running,
"current_device": analyzer.current_device, "current_device": analyzer.current_device,
"unavailable_reason": reason,
} }
@@ -285,7 +371,7 @@ async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, s
"""List available loopback audio devices for the visualizer.""" """List available loopback audio devices for the visualizer."""
from ..services.audio_analyzer import AudioAnalyzer from ..services.audio_analyzer import AudioAnalyzer
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices) return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
@@ -323,12 +409,17 @@ async def set_visualizer_device(
@router.websocket("/ws") @router.websocket("/ws")
async def websocket_endpoint( async def websocket_endpoint(
websocket: WebSocket, websocket: WebSocket,
token: str | None = Query(None, description="API authentication token"), token: str | None = Query(None, description="API authentication token (legacy)"),
) -> None: ) -> None:
"""WebSocket endpoint for real-time media status updates. """WebSocket endpoint for real-time media status updates.
Authentication is done via query parameter since WebSocket Authentication is accepted from two sources, in priority order:
doesn't support custom headers in the browser. 1. ``Sec-WebSocket-Protocol`` subprotocol of the form
``media-server.token.<TOKEN>``. This is the preferred path because
the token never lands in the URL, request logs, or browser history.
The browser WebSocket API supports custom subprotocols natively.
2. ``?token=<TOKEN>`` query parameter (legacy, kept for back-compat
with older clients and the HA integration).
Messages sent to client: Messages sent to client:
- {"type": "status", "data": {...}} - Initial status on connect - {"type": "status", "data": {...}} - Initial status on connect
@@ -339,11 +430,54 @@ async def websocket_endpoint(
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"} - {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
- {"type": "get_status"} - Request current status - {"type": "get_status"} - Request current status
""" """
# Pull token from subprotocol if present. WebSocket spec lets either side
# negotiate exactly one subprotocol back; we accept the token one and
# answer with the same string so browsers consider the negotiation
# successful.
subprotocol_token: str | None = None
accept_subprotocol: str | None = None
raw_protocols = websocket.headers.get("sec-websocket-protocol", "")
for proto in (p.strip() for p in raw_protocols.split(",") if p.strip()):
if proto.startswith("media-server.token."):
subprotocol_token = proto[len("media-server.token."):]
accept_subprotocol = proto
break
effective_token = subprotocol_token or token
# Origin check — block CSWSH from third-party LAN pages. Accept the same
# set of origins as CORS plus the default localhost loopback, AND any
# same-origin connection (where Origin matches the request's Host header).
# Same-origin is inherently safe from CSWSH because CSWSH is a *cross*-
# origin attack — without this, binding to 0.0.0.0 and accessing the UI
# via a LAN IP would have its WebSocket rejected by the browser-sent
# Origin, which the static allowlist can't anticipate.
allowed_origins = set(
settings.cors_origins
or [
f"http://localhost:{settings.port}",
f"http://127.0.0.1:{settings.port}",
]
)
origin = websocket.headers.get("origin")
# Same-origin connections from native apps may omit Origin entirely; only
# reject when an Origin is present AND not in the allow-list.
if origin is not None and origin not in allowed_origins:
host_header = websocket.headers.get("host", "")
# Origin uses http/https; match against both scheme variants of Host
# so HTTPS deployments without an explicit cors_origins still work.
same_origin_candidates = (
{f"http://{host_header}", f"https://{host_header}"}
if host_header
else set()
)
if origin not in same_origin_candidates:
await websocket.close(code=4003, reason="Origin not allowed")
return
# Verify token # Verify token
from ..auth import auth_enabled, get_token_label, token_label_var from ..auth import auth_enabled, get_token_label, token_label_var
if auth_enabled(): if auth_enabled():
label = get_token_label(token) if token else None label = get_token_label(effective_token) if effective_token else None
if label is None: if label is None:
await websocket.close(code=4001, reason="Invalid authentication token") await websocket.close(code=4001, reason="Invalid authentication token")
return return
@@ -351,16 +485,25 @@ async def websocket_endpoint(
else: else:
token_label_var.set("anonymous") token_label_var.set("anonymous")
await ws_manager.connect(websocket) # Accept with the negotiated subprotocol if one was used. Starlette's
# connect() calls accept() with no subprotocol — we need to accept first
# explicitly to echo the subprotocol back, then hand off to the manager.
if accept_subprotocol is not None:
await websocket.accept(subprotocol=accept_subprotocol)
await ws_manager.connect(websocket, already_accepted=True)
else:
await ws_manager.connect(websocket)
try: try:
while True: while True:
# Wait for messages from client (for keepalive/ping) # Wait for messages from client (for keepalive/ping)
data = await websocket.receive_json() data = await websocket.receive_json()
if data.get("type") == "ping": msg_type = data.get("type") if isinstance(data, dict) else None
if msg_type == "ping":
await websocket.send_json({"type": "pong"}) await websocket.send_json({"type": "pong"})
elif data.get("type") == "get_status": elif msg_type == "get_status":
# Allow manual status request # Allow manual status request
controller = get_media_controller() controller = get_media_controller()
status_data = await controller.get_status() status_data = await controller.get_status()
@@ -368,15 +511,20 @@ async def websocket_endpoint(
"type": "status", "type": "status",
"data": status_data.model_dump(), "data": status_data.model_dump(),
}) })
elif data.get("type") == "volume": elif msg_type == "volume":
# Low-latency volume control via WebSocket # Low-latency volume control via WebSocket. Coerce, clamp, and
volume = data.get("volume") # never drop the socket on a single bad message — that would
if volume is not None: # turn the WS into a one-shot DoS for any holder of a token.
controller = get_media_controller() try:
await controller.set_volume(int(volume)) volume = int(data.get("volume"))
elif data.get("type") == "enable_visualizer": except (TypeError, ValueError):
continue
volume = max(0, min(100, volume))
controller = get_media_controller()
await controller.set_volume(volume)
elif msg_type == "enable_visualizer":
await ws_manager.subscribe_visualizer(websocket) await ws_manager.subscribe_visualizer(websocket)
elif data.get("type") == "disable_visualizer": elif msg_type == "disable_visualizer":
await ws_manager.unsubscribe_visualizer(websocket) await ws_manager.unsubscribe_visualizer(websocket)
except WebSocketDisconnect: except WebSocketDisconnect:
+113 -7
View File
@@ -2,18 +2,22 @@
import asyncio import asyncio
import logging import logging
import os
import re import re
import subprocess import subprocess
import sys
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..auth import verify_token from ..auth import verify_token
from ..config import ScriptConfig, ScriptParameterConfig, settings from ..config import ScriptConfig, ScriptParameterConfig, settings
from ..config_manager import config_manager from ..config_manager import config_manager
from ..services.rate_limit import check as ratelimit_check
from ..services.rate_limit import get_peer
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"]) router = APIRouter(prefix="/api/scripts", tags=["scripts"])
@@ -23,6 +27,36 @@ _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def shutdown_script_executor() -> None:
"""Shut down the dedicated executor cleanly on application teardown."""
_script_executor.shutdown(wait=False, cancel_futures=True)
def _require_scripts_management() -> None:
"""Authorise a scripts-CRUD operation.
Two gates: the operator-level `scripts_management` flag in config.yaml,
AND the per-token `admin` scope check (read from request-context). Either
failure 403.
"""
if not settings.scripts_management:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Scripts management is disabled. Set scripts_management: true"
" in config.yaml to enable."
),
)
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: admin",
)
class ScriptExecuteRequest(BaseModel): class ScriptExecuteRequest(BaseModel):
"""Request model for script execution with optional parameters.""" """Request model for script execution with optional parameters."""
@@ -197,6 +231,28 @@ def _validate_params(
# string — just convert to str # string — just convert to str
value = str(value) value = str(value)
# Optional regex constraint, validated against the *string form* of the
# value. This is the only practical defence for string parameters that
# flow into shell=true scripts via env vars (Windows cmd.exe expands
# `%VAR%` after argument parsing, so embedded `&`/`|`/`%` would inject
# commands). Authors of shell scripts should ALWAYS define a pattern.
if pdef.pattern:
try:
if not re.fullmatch(pdef.pattern, str(value)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Parameter '{pname}' value {value!r} does not match"
f" required pattern: {pdef.pattern}"
),
)
except re.error as e:
# Bad pattern in config — fail closed.
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Parameter '{pname}' has invalid pattern: {e}",
) from e
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value) env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
return env_vars return env_vars
@@ -205,6 +261,7 @@ def _validate_params(
@router.post("/execute/{script_name}") @router.post("/execute/{script_name}")
async def execute_script( async def execute_script(
script_name: str, script_name: str,
http_request: Request,
request: ScriptExecuteRequest | None = None, request: ScriptExecuteRequest | None = None,
_: str = Depends(verify_token), _: str = Depends(verify_token),
) -> ScriptExecuteResponse: ) -> ScriptExecuteResponse:
@@ -217,6 +274,16 @@ async def execute_script(
Returns: Returns:
Execution result including stdout, stderr, and exit code Execution result including stdout, stderr, and exit code
""" """
# Rate-limit script execution per peer so a leaked token can't be used to
# spam the shell-exec endpoint.
allowed, retry_after = ratelimit_check("execute", get_peer(http_request))
if not allowed:
raise HTTPException(
status_code=429,
detail="Too many script executions, slow down",
headers={"Retry-After": str(int(retry_after or 60))},
)
# Check if script exists # Check if script exists
if script_name not in settings.scripts: if script_name not in settings.scripts:
raise HTTPException( raise HTTPException(
@@ -231,9 +298,11 @@ async def execute_script(
logger.info(f"Executing script: {script_name}") logger.info(f"Executing script: {script_name}")
from ..services.audit_log import record_script_execution
try: try:
# Execute in dedicated thread pool to not block the default executor # Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
_script_executor, _script_executor,
lambda: _run_script( lambda: _run_script(
@@ -245,6 +314,15 @@ async def execute_script(
), ),
) )
record_script_execution(
kind="script",
name=script_name,
exit_code=result["exit_code"],
duration=result.get("execution_time"),
stdout=result.get("stdout"),
stderr=result.get("stderr"),
)
return ScriptExecuteResponse( return ScriptExecuteResponse(
success=result["exit_code"] == 0, success=result["exit_code"] == 0,
script=script_name, script=script_name,
@@ -256,6 +334,13 @@ async def execute_script(
except Exception as e: except Exception as e:
logger.error(f"Script execution error: {e}") logger.error(f"Script execution error: {e}")
record_script_execution(
kind="script",
name=script_name,
exit_code=None,
duration=None,
error=str(e),
)
return ScriptExecuteResponse( return ScriptExecuteResponse(
success=False, success=False,
script=script_name, script=script_name,
@@ -285,17 +370,38 @@ def _run_script(
start_time = time.time() start_time = time.time()
env = None env = None
if extra_env: if extra_env:
import os
env = {**os.environ, **extra_env} env = {**os.environ, **extra_env}
# Spawn the script in its own process group / job so a timeout kills the
# whole tree, not just the shell (POSIX) and not just the parent (Windows).
popen_kwargs: dict[str, Any] = {}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
# When shell=False, the user-provided command string is split via shlex
# (POSIX rules — also works for Windows args without backslashes). This
# disables shell metacharacter expansion entirely, so SCRIPT_PARAM_* env
# vars referenced as $FOO / %FOO% will be treated as literal text by the
# process, not interpreted by a shell. Use shell=false for any script
# whose params come from external input.
if shell:
run_command: str | list[str] = command
else:
import shlex
run_command = shlex.split(command, posix=(sys.platform != "win32"))
try: try:
result = subprocess.run( result = subprocess.run(
command, run_command,
shell=shell, shell=shell,
cwd=working_dir, cwd=working_dir,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
env=env, env=env,
**popen_kwargs,
) )
execution_time = time.time() - start_time execution_time = time.time() - start_time
return { return {
@@ -455,7 +561,7 @@ async def create_script(
Raises: Raises:
HTTPException: If script already exists or name is invalid. HTTPException: If script already exists or name is invalid.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script already exists # Check if script already exists
@@ -511,7 +617,7 @@ async def update_script(
Raises: Raises:
HTTPException: If script does not exist. HTTPException: If script does not exist.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script exists # Check if script exists
@@ -565,7 +671,7 @@ async def delete_script(
Raises: Raises:
HTTPException: If script does not exist. HTTPException: If script does not exist.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script exists # Check if script exists
+19 -6
View File
@@ -57,25 +57,38 @@ install_service() {
# Create installation directory # Create installation directory
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
# Copy source files # Resolve the source-tree root (two levels up from this script:
cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/" # media_server/service/install_linux.sh → repo root).
SOURCE_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Copy the source tree (pyproject.toml + media_server/ package)
cp -r "$SOURCE_ROOT/." "$INSTALL_DIR/"
# Create virtual environment # Create virtual environment
echo_info "Creating Python virtual environment..." echo_info "Creating Python virtual environment..."
python3 -m venv "$INSTALL_DIR/venv" python3 -m venv "$INSTALL_DIR/venv"
# Install Python dependencies # Install Python dependencies from pyproject.toml (with linux extra).
# cd into the install dir so pip resolves `.[linux]` against the local
# pyproject — passing a path-with-extras (`$INSTALL_DIR[linux]`) trips
# the requirement-spec parser on some pip versions.
echo_info "Installing Python dependencies..." echo_info "Installing Python dependencies..."
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip "$INSTALL_DIR/venv/bin/pip" install --upgrade pip
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" (cd "$INSTALL_DIR" && "$INSTALL_DIR/venv/bin/pip" install ".[linux]")
# Install systemd service file # Install systemd service file (templated unit)
echo_info "Installing systemd service..." echo_info "Installing systemd service..."
cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE" cp "$INSTALL_DIR/media_server/service/media-server.service" "$SERVICE_FILE"
# Reload systemd # Reload systemd
systemctl daemon-reload systemctl daemon-reload
# Pre-create writable state dirs so the unit's ReadWritePaths grant
# actually has something to grant.
sudo -u "$SUDO_USER" mkdir -p \
"/home/$SUDO_USER/.config/media-server" \
"/home/$SUDO_USER/.cache/media-server"
# Generate config if not exists # Generate config if not exists
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
echo_info "Generating configuration file..." echo_info "Generating configuration file..."
+19 -15
View File
@@ -3,34 +3,38 @@ Description=Media Server - REST API for controlling system media playback
After=network.target sound.target After=network.target sound.target
Wants=sound.target Wants=sound.target
# Templated unit. Enable as:
# sudo systemctl enable --now media-server@$USER
# %i is the user name supplied after the '@'; %U is the matching numeric UID.
[Service] [Service]
Type=simple Type=simple
User=%i User=%i
Group=%i Group=%i
# Environment variables (optional - can also use config file) # Working directory (override via drop-in if you install elsewhere)
# Environment=MEDIA_SERVER_HOST=0.0.0.0
# Environment=MEDIA_SERVER_PORT=8765
# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token
# Working directory
WorkingDirectory=/opt/media-server WorkingDirectory=/opt/media-server
# Start command - adjust path to your Python environment # Start command adjust to match where you installed the venv. --no-tray
ExecStart=/opt/media-server/venv/bin/python -m media_server.main # avoids pulling pystray into a headless service environment.
ExecStart=/opt/media-server/venv/bin/python -m media_server.main --no-tray
# Restart policy Restart=on-failure
Restart=always
RestartSec=10 RestartSec=10
# Security hardening # Required for MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
Environment=PYTHONUNBUFFERED=1
Environment=XDG_RUNTIME_DIR=/run/user/%U
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
# Light sandboxing. ProtectHome=read-only by itself blocks the app's own
# audit.log / thumbnail cache writes — ReadWritePaths re-opens just the
# two state dirs the server owns.
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=read-only ProtectHome=read-only
PrivateTmp=true ReadWritePaths=/home/%i/.config/media-server /home/%i/.cache/media-server
# Required for D-Bus access (MPRIS)
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+28 -2
View File
@@ -65,7 +65,12 @@ def get_media_controller() -> "MediaController":
def get_current_album_art() -> bytes | None: def get_current_album_art() -> bytes | None:
"""Get the current album art bytes (Windows only for now).""" """Get the current album art bytes (synchronous, Windows-cached path).
Windows pre-populates a module-level cache via the WinRT polling thread,
so this stays sync. For Linux/macOS the controller fetches on demand
use ``get_current_album_art_async()`` from FastAPI handlers instead.
"""
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
from .windows_media import get_current_album_art as _get_art from .windows_media import get_current_album_art as _get_art
@@ -73,6 +78,22 @@ def get_current_album_art() -> bytes | None:
return None return None
async def get_current_album_art_async() -> bytes | None:
"""Cross-platform album art fetch. Awaits the controller's impl.
Falls back to the sync Windows cache when running on Windows so we don't
pay an extra coroutine hop for the existing path.
"""
system = platform.system()
if system == "Windows":
return get_current_album_art()
controller = get_media_controller()
try:
return await controller.get_album_art()
except Exception: # noqa: BLE001 — art is best-effort; never break the route
return None
def get_audio_devices() -> list[dict[str, str]]: def get_audio_devices() -> list[dict[str, str]]:
"""Get list of available audio output devices (Windows only for now).""" """Get list of available audio output devices (Windows only for now)."""
system = platform.system() system = platform.system()
@@ -82,4 +103,9 @@ def get_audio_devices() -> list[dict[str, str]]:
return [] return []
__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"] __all__ = [
"get_media_controller",
"get_current_album_art",
"get_current_album_art_async",
"get_audio_devices",
]
+13
View File
@@ -72,6 +72,11 @@ class AudioAnalyzer:
self._lifecycle_lock = threading.Lock() self._lifecycle_lock = threading.Lock()
self._data: dict | None = None self._data: dict | None = None
self._current_device_name: str | None = None self._current_device_name: str | None = None
# Sticky "no usable device" flag — flipped to True if a capture
# attempt fails because no loopback device exists. Prevents the
# WebSocket manager from looping on start()/stop()/start() forever
# when there's nothing to capture. Cleared by set_device().
self._unavailable = False
# Generation counter — bumped each time _data is refreshed. # Generation counter — bumped each time _data is refreshed.
# Lets the broadcast loop dedupe without comparing dict identity # Lets the broadcast loop dedupe without comparing dict identity
# (which is fragile because we always allocate a new dict). # (which is fragile because we always allocate a new dict).
@@ -123,6 +128,10 @@ class AudioAnalyzer:
return True return True
if not self.available: if not self.available:
return False return False
if self._unavailable:
# We already tried and failed to acquire a device. Don't
# spin a new capture thread for each new subscriber.
return False
# Reset AGC envelope so a long silent gap between sessions # Reset AGC envelope so a long silent gap between sessions
# doesn't make the first new transients clip at the ceiling. # doesn't make the first new transients clip at the ceiling.
@@ -235,6 +244,9 @@ class AudioAnalyzer:
self.device_name = device_name self.device_name = device_name
self._current_device_name = None self._current_device_name = None
# Clear the "no device" sticky flag — the user is asking for a
# different device so it's worth attempting capture again.
self._unavailable = False
if was_running: if was_running:
return self.start() return self.start()
@@ -269,6 +281,7 @@ class AudioAnalyzer:
if device is None: if device is None:
logger.warning("No loopback audio device found - visualizer disabled") logger.warning("No loopback audio device found - visualizer disabled")
self._running = False self._running = False
self._unavailable = True
return return
interval = 1.0 / self.target_fps interval = 1.0 / self.target_fps
+120
View File
@@ -0,0 +1,120 @@
"""Append-only audit log for sensitive actions (script + callback execution).
Writes a single JSONL line per event to ``<config_dir>/audit.log``. The log is
write-only from the app's perspective — it never reads back, and rotation is
left to the operator (the file size is dominated by stdout/stderr truncation,
which is already capped at 10 KB per stream in `_run_script`).
Designed to be cheap: the write goes through a small background thread so the
hot path never blocks on disk I/O, and a failure to write is logged at WARNING
but never raised to callers.
"""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from typing import Any
from ..auth import token_label_var
from ..config import get_config_dir
logger = logging.getLogger(__name__)
# Cap on stdout/stderr inside the audit record so a chatty script doesn't
# explode the log. Mirrors the 10k cap used by _run_script.
_OUTPUT_CAP = 2000
_audit_queue: "queue.Queue[dict[str, Any] | None]" = queue.Queue(maxsize=1000)
_audit_thread: threading.Thread | None = None
_audit_lock = threading.Lock()
def _ensure_writer_started() -> None:
global _audit_thread
with _audit_lock:
if _audit_thread is not None and _audit_thread.is_alive():
return
_audit_thread = threading.Thread(
target=_audit_writer_loop,
name="audit-log",
daemon=True,
)
_audit_thread.start()
def _audit_writer_loop() -> None:
log_path = get_config_dir() / "audit.log"
while True:
try:
record = _audit_queue.get()
except Exception:
return
if record is None:
return
try:
line = json.dumps(record, ensure_ascii=False, default=str)
with open(log_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError as e:
logger.warning("Failed to write audit record: %s", e)
def _truncate(value: str | None) -> str | None:
if value is None:
return None
if len(value) <= _OUTPUT_CAP:
return value
return value[:_OUTPUT_CAP] + f"\n…[truncated, {len(value) - _OUTPUT_CAP} chars]"
def record_script_execution(
*,
kind: str,
name: str,
exit_code: int | None,
duration: float | None,
stdout: str | None = None,
stderr: str | None = None,
error: str | None = None,
) -> None:
"""Append a single audit record. Never raises."""
_ensure_writer_started()
try:
record = {
"ts": time.time(),
"iso": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()),
"token_label": token_label_var.get("unknown"),
"kind": kind,
"name": name,
"exit_code": exit_code,
"duration_s": round(duration, 4) if duration is not None else None,
"success": exit_code == 0 if exit_code is not None else False,
"stdout": _truncate(stdout),
"stderr": _truncate(stderr),
"error": error,
}
_audit_queue.put_nowait(record)
except queue.Full:
# Backpressure: drop oldest record to make room. We'd rather lose an
# old entry than block the script that just ran.
try:
_audit_queue.get_nowait()
_audit_queue.put_nowait(record)
except queue.Empty:
pass
except Exception as e:
logger.warning("Failed to enqueue audit record: %s", e)
def shutdown_audit_log() -> None:
"""Flush the audit queue on app shutdown."""
try:
_audit_queue.put_nowait(None)
except queue.Full:
pass
if _audit_thread is not None:
_audit_thread.join(timeout=2)
+20 -6
View File
@@ -63,14 +63,28 @@ class BrowserService:
if not base_path.is_dir(): if not base_path.is_dir():
raise ValueError(f"Media folder path is not a directory: {base_path}") raise ValueError(f"Media folder path is not a directory: {base_path}")
# Handle relative vs absolute paths # Reject absolute paths, drive letters, UNC paths, and NUL bytes outright.
if requested_path.startswith("/") or requested_path.startswith("\\"): # Only true folder-relative paths are accepted.
# Relative to folder root (remove leading slash) if "\x00" in requested_path:
requested_path = requested_path.lstrip("/\\") raise ValueError("Path contains NUL byte")
# Strip a single leading "/" or "\\" (legacy callers send "/sub/dir") but
# then refuse anything that still looks absolute.
cleaned = requested_path.lstrip("/\\")
# Detect Windows drive letter like "C:/..." after stripping.
if len(cleaned) >= 2 and cleaned[1] == ":":
raise ValueError("Absolute paths are not allowed")
# Detect raw UNC ("\\\\server\\share") — the lstrip above strips at most
# one leading slash, so a UNC original starts with another "\\" or "/".
if cleaned.startswith("\\") or cleaned.startswith("/"):
raise ValueError("Absolute paths are not allowed")
candidate = Path(cleaned) if cleaned else None
if candidate is not None and candidate.is_absolute():
raise ValueError("Absolute paths are not allowed")
# Build and resolve full path # Build and resolve full path
if requested_path: if cleaned:
full_path = (base_path / requested_path).resolve() full_path = (base_path / cleaned).resolve()
else: else:
full_path = base_path full_path = base_path
@@ -0,0 +1,296 @@
"""Extract page-level metadata from a focused desktop web browser.
The browser's window title is the reliable signal — every major browser
formats it as ``"<page title> - <Browser Name>"``, so stripping the suffix
gives us the page title for free.
URL extraction was attempted via UI Automation (UIA), but Chromium-based
browsers (Chrome/Edge/Brave/Vivaldi) keep their accessibility tree dormant
unless a screen reader is active or ``--force-renderer-accessibility`` is
set neither is something we want to require from end users. The UIA
machinery is still here behind a feature flag in case a future caller
opts into the accessibility-flag path; by default we just return the
page title and leave ``url=None``.
Other platforms (macOS via AppleScript, Linux via AT-SPI) are out of scope
for this iteration.
"""
from __future__ import annotations
import logging
import os
import platform
import threading
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# UIA URL extraction is opt-in because Chromium browsers keep their
# accessibility tree dormant unless the user starts the browser with
# ``--force-renderer-accessibility`` (or a screen reader is running).
# Without that, `FindAll` throws and we'd burn 5s per probe retrying.
# Set MEDIA_SERVER_BROWSER_UIA=1 to enable; default off.
_UIA_ENABLED = os.environ.get("MEDIA_SERVER_BROWSER_UIA", "").lower() in (
"1", "true", "yes", "on"
)
# Known browser executables (lowercase, .exe-stripped). Used to decide
# whether to spend the UIA query budget on this foreground process.
BROWSER_PROCESS_HINTS: frozenset[str] = frozenset({
"chrome",
"msedge",
"firefox",
"brave",
"opera",
"vivaldi",
"yandex",
"browser", # Yandex Browser sometimes reports as browser.exe
"arc",
"thorium",
})
@dataclass(frozen=True)
class BrowserPageInfo:
url: str | None = None
page_title: str | None = None
_EMPTY = BrowserPageInfo()
def is_browser_process(process_name: str | None) -> bool:
"""Return True when ``process_name`` looks like a supported browser."""
if not process_name:
return False
base = process_name.lower()
if base.endswith(".exe"):
base = base[:-4]
return base in BROWSER_PROCESS_HINTS
def _strip_browser_suffix(title: str | None, process_name: str | None) -> str | None:
"""Pull the page title out of the browser's window title.
Most browsers format their window title as ``"<page> - <Browser Name>"``.
We strip the trailing suffix so consumers get the page title alone. If
the suffix can't be matched, return the raw title unchanged.
"""
if not title:
return None
suffixes = (
" - Google Chrome",
" — Google Chrome",
" - MicrosoftEdge",
" - Microsoft Edge",
" — Mozilla Firefox",
" - Mozilla Firefox",
" - Brave",
" - Opera",
" - Vivaldi",
" - Yandex",
)
for s in suffixes:
if title.endswith(s):
return title[: -len(s)].strip() or None
return title
# ─── UIA lookup (Windows) ───────────────────────────────────────────
# UIA control type / property constants we need. Avoiding the full
# UIAutomationClient typelib generation — those constants are stable.
_UIA_EditControlTypeId = 50004
_UIA_ControlTypePropertyId = 30003
_UIA_ValueValuePropertyId = 30045
_UIA_NamePropertyId = 30005
_UIA_ValuePatternId = 10002
_TreeScope_Descendants = 4
_PropertyConditionFlags_IgnoreCase = 1
# Lazy import + per-thread COM init.
_uia_lock = threading.Lock()
_uia_singleton = None
_uia_load_error: str | None = None
_uia_thread_local = threading.local()
def _ensure_com() -> None:
"""Initialise COM on the current thread (idempotent per thread)."""
if getattr(_uia_thread_local, "initialised", False):
return
try:
import comtypes # type: ignore
# COINIT_APARTMENTTHREADED is required by UIA; comtypes' default
# CoInitializeEx already passes that flag.
comtypes.CoInitialize()
_uia_thread_local.initialised = True
except Exception as e:
logger.debug("CoInitialize failed: %s", e)
def _get_uia():
"""Return the IUIAutomation singleton, or None if unavailable."""
global _uia_singleton, _uia_load_error
if _uia_singleton is not None:
return _uia_singleton
if _uia_load_error is not None:
return None
with _uia_lock:
if _uia_singleton is not None:
return _uia_singleton
try:
import comtypes.client # type: ignore
# CLSID for CUIAutomation. Using GetActiveObject would fail,
# so we cocreate. comtypes.client.CreateObject keeps the COM
# plumbing tidy.
_uia_singleton = comtypes.client.CreateObject(
"{ff48dba4-60ef-4201-aa87-54103eef594e}",
interface=comtypes.client.GetModule(
"UIAutomationCore.dll"
).IUIAutomation,
)
return _uia_singleton
except Exception as e:
_uia_load_error = str(e)
logger.info("UIA unavailable; browser URL extraction disabled: %s", e)
return None
def _find_address_bar_value(hwnd: int) -> str | None:
"""Walk the UIA tree under ``hwnd`` looking for the URL Edit control.
Strategy: find every descendant Edit control, then pick the first one
whose Name contains an address-bar hint, or failing that the first
one whose value parses as a URL-ish string. Browsers expose extra Edit
controls (search bars, find-in-page) so name matching is the reliable
signal; the URL-ish fallback covers locale variants we haven't seen.
"""
_ensure_com()
uia = _get_uia()
if uia is None:
return None
try:
element = uia.ElementFromHandle(hwnd)
if not element:
return None
# Build a condition matching ControlType=Edit, then enumerate.
edit_condition = uia.CreatePropertyCondition(
_UIA_ControlTypePropertyId, _UIA_EditControlTypeId
)
edits = element.FindAll(_TreeScope_Descendants, edit_condition)
count = edits.Length if edits else 0
if count == 0:
return None
# Hints (lowercase) used to identify the address bar by its Name
# property. Covers en-US plus a few common locales / browsers.
name_hints = (
"address", # Chrome/Edge: "Address and search bar"
"адрес", # Chrome ru: "Адресная строка и строка поиска"
"адресная",
"search with", # Firefox: "Search with Google or enter address"
"поиск или ввод", # Firefox ru
"url",
"location",
)
# First pass: name-based match (high confidence).
candidates: list[tuple[int, str]] = []
for i in range(count):
edit = edits.GetElement(i)
try:
name = (edit.CurrentName or "").lower()
except Exception:
name = ""
try:
value = edit.GetCurrentPropertyValue(_UIA_ValueValuePropertyId)
except Exception:
value = None
if value is None:
continue
value_str = str(value)
for h in name_hints:
if h in name:
return value_str
candidates.append((i, value_str))
# Second pass: URL-ish fallback. Pick the first candidate that
# looks like a URL; this catches browser/locale combos we haven't
# listed above.
for _i, v in candidates:
lv = v.lower()
if (
lv.startswith("http://")
or lv.startswith("https://")
or lv.startswith("about:")
or lv.startswith("chrome://")
or lv.startswith("edge://")
or lv.startswith("brave://")
or lv.startswith("file://")
or lv.startswith("ftp://")
):
return v
return None
except Exception as e:
logger.debug("UIA address-bar lookup failed: %s", e)
return None
# ─── Per-(hwnd, title) cache ────────────────────────────────────────
_cache_lock = threading.Lock()
_cache_key: tuple[int | None, str | None] = (None, None)
_cache_value: BrowserPageInfo = _EMPTY
def get_browser_page(
*,
hwnd: int | None,
process_name: str | None,
window_title: str | None,
) -> BrowserPageInfo:
"""Return the URL + page title for the foreground browser tab, if any.
Callers pass the already-resolved foreground HWND/title/process_name so
this service doesn't re-walk Win32 to find them. Returns ``_EMPTY`` for
non-browser processes or when UIA can't resolve the URL.
"""
if not is_browser_process(process_name):
return _EMPTY
if platform.system() != "Windows":
# macOS/Linux paths not implemented in this iteration.
return _EMPTY
if not hwnd:
return _EMPTY
global _cache_key, _cache_value
key = (hwnd, window_title)
with _cache_lock:
if key == _cache_key and _cache_value is not _EMPTY:
return _cache_value
url = _find_address_bar_value(hwnd) if _UIA_ENABLED else None
page_title = _strip_browser_suffix(window_title, process_name)
info = BrowserPageInfo(url=url, page_title=page_title)
with _cache_lock:
_cache_key = key
_cache_value = info
return info
def reset_cache() -> None:
"""Reset the cache. Useful in tests."""
global _cache_key, _cache_value
with _cache_lock:
_cache_key = (None, None)
_cache_value = _EMPTY
+426 -21
View File
@@ -1,4 +1,4 @@
"""Display brightness and power control service.""" """Display brightness, power, contrast, input-source and color-preset control."""
import ctypes import ctypes
import ctypes.wintypes import ctypes.wintypes
@@ -6,10 +6,33 @@ import logging
import platform import platform
import struct import struct
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# VCP 0xDC "Display Application" — picture / scene mode.
# Vendors deviate from the MCCS spec, but these labels match the standard
# meanings and cover what most monitors report through their capability
# string. Unknown codes fall back to "Mode <n>".
PICTURE_MODE_VCP = 0xDC
PICTURE_MODE_LABELS: dict[int, str] = {
0x00: "Default",
0x01: "Standalone",
0x02: "Mixed",
0x03: "Productivity",
0x04: "Movie",
0x05: "Game",
0x06: "Sports",
0x07: "Professional",
0x08: "Standard",
0x09: "Default",
0x0A: "Movie (Reduced Effects)",
0x0B: "Movie (Enhanced)",
0x0C: "User 1",
0x0D: "User 2",
0x0E: "User 3",
}
_sbc = None _sbc = None
_monitorcontrol = None _monitorcontrol = None
@@ -32,7 +55,7 @@ def _load_monitorcontrol():
import monitorcontrol import monitorcontrol
_monitorcontrol = monitorcontrol _monitorcontrol = monitorcontrol
except ImportError: except ImportError:
logger.warning("monitorcontrol not installed - display power control unavailable") logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
return _monitorcontrol return _monitorcontrol
@@ -64,6 +87,18 @@ class MonitorInfo:
manufacturer: str = "" manufacturer: str = ""
resolution: str | None = None resolution: str | None = None
is_primary: bool = False is_primary: bool = False
contrast: int | None = None
contrast_supported: bool = False
input_source: str | None = None
available_input_sources: list[str] = field(default_factory=list)
input_source_supported: bool = False
color_preset: str | None = None
available_color_presets: list[str] = field(default_factory=list)
color_preset_supported: bool = False
picture_mode: str | None = None
picture_mode_code: int | None = None
available_picture_modes: list[dict] = field(default_factory=list)
picture_mode_supported: bool = False
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -76,6 +111,18 @@ class MonitorInfo:
"manufacturer": self.manufacturer, "manufacturer": self.manufacturer,
"resolution": self.resolution, "resolution": self.resolution,
"is_primary": self.is_primary, "is_primary": self.is_primary,
"contrast": self.contrast,
"contrast_supported": self.contrast_supported,
"input_source": self.input_source,
"available_input_sources": self.available_input_sources,
"input_source_supported": self.input_source_supported,
"color_preset": self.color_preset,
"available_color_presets": self.available_color_presets,
"color_preset_supported": self.color_preset_supported,
"picture_mode": self.picture_mode,
"picture_mode_code": self.picture_mode_code,
"available_picture_modes": self.available_picture_modes,
"picture_mode_supported": self.picture_mode_supported,
} }
@@ -137,17 +184,184 @@ def _mark_primary(monitors: list[MonitorInfo]) -> None:
monitors[0].is_primary = True monitors[0].is_primary = True
# Cache for monitor list # Short TTL cache of the assembled monitor list (full response).
_monitor_cache: list[MonitorInfo] | None = None _monitor_cache: list[MonitorInfo] | None = None
_cache_time: float = 0 _cache_time: float = 0
_CACHE_TTL = 5.0 # seconds _CACHE_TTL = 5.0 # seconds
# Per-monitor cache of static capabilities (option lists + support flags).
# DDC/CI capability discovery is the slow part — it only changes when a
# monitor is replaced or rewired, so we probe it once per monitor and reuse
# it across refreshes. Keyed by a stable identity tuple
# (manufacturer, model, edid_hash) so that hot-plug swaps where the new
# topology has the same number of monitors but different devices still
# refresh the cache for the new monitor instead of serving stale capabilities.
_static_cache: dict[tuple, dict] = {}
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
"""List all connected monitors with their current brightness.""" def _enum_name(value, enum_cls=None) -> str | None:
"""Best-effort name for an enum or raw int returned by monitorcontrol.
monitorcontrol's getters sometimes hand back raw ints when the monitor
reports a value the library wraps incompletely. Re-map those through the
matching enum class so HA selects still receive symbolic option names.
"""
if value is None:
return None
name = getattr(value, "name", None)
if name:
return name
if enum_cls is not None:
try:
return enum_cls(value).name
except (ValueError, KeyError):
pass
return str(value)
def _probe_static_open(mon, mc, monitor_id: int) -> dict:
"""Probe per-monitor static capabilities.
Must be called inside an open `with mon:` DDC/CI context. Tries each
feature once to confirm the monitor responds, and enumerates option
lists from the capability string. Heavy: this is what the cache is for.
"""
static = {
"contrast_supported": False,
"input_source_supported": False,
"available_input_sources": [],
"color_preset_supported": False,
"available_color_presets": [],
"picture_mode_supported": False,
"available_picture_modes": [],
}
try:
caps = mon.get_vcp_capabilities() or {}
except Exception as e:
caps = {}
logger.debug("Monitor %d: get_vcp_capabilities failed: %s", monitor_id, e)
try:
mon.get_contrast()
static["contrast_supported"] = True
except Exception as e:
logger.debug("Monitor %d: contrast unsupported: %s", monitor_id, e)
try:
mon.get_input_source()
static["input_source_supported"] = True
except Exception as e:
logger.debug("Monitor %d: input_source unsupported: %s", monitor_id, e)
inputs = caps.get("inputs") or []
input_enum = mc.InputSource if mc else None
static["available_input_sources"] = [
n for n in (_enum_name(s, input_enum) for s in inputs) if n is not None
]
try:
mon.get_color_preset()
static["color_preset_supported"] = True
if mc is not None:
static["available_color_presets"] = [p.name for p in mc.ColorPreset]
except Exception as e:
logger.debug("Monitor %d: color_preset unsupported: %s", monitor_id, e)
# Picture / scene mode (VCP 0xDC). Trickier than color preset because
# many monitors (LG ultrawides included) respond to READS but silently
# drop every WRITE - they implement the register but not the feature.
# The capability string is the most reliable signal: a monitor that
# really implements picture mode declares its supported codes under
# cmds[0xDC]. If 0xDC isn't declared, treat the feature as unsupported
# to avoid exposing a non-functional select.
cmds = caps.get("cmds") or {}
declared = cmds.get(PICTURE_MODE_VCP)
if declared:
try:
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
static["picture_mode_supported"] = True
static["available_picture_modes"] = [
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
for c in sorted(declared)
]
except Exception as e:
logger.debug("Monitor %d: picture_mode declared but unreadable: %s", monitor_id, e)
else:
logger.debug("Monitor %d: picture_mode (VCP 0xDC) not declared in capability string", monitor_id)
return static
def _probe_dynamic_open(mon, mc, monitor_id: int, static: dict) -> dict:
"""Read current values for features known to be supported.
Must be called inside an open `with mon:` context. Skips reads for
unsupported features (saves one I2C roundtrip each), so the warm path
only touches features the monitor actually responds to.
"""
dynamic = {
"power_on": True,
"contrast": None,
"input_source": None,
"color_preset": None,
"picture_mode": None,
"picture_mode_code": None,
}
try:
dynamic["power_on"] = mon.get_power_mode() == mc.PowerMode.on
except Exception as e:
logger.debug("Monitor %d: power readback failed: %s", monitor_id, e)
if static.get("contrast_supported"):
try:
dynamic["contrast"] = mon.get_contrast()
except Exception as e:
logger.debug("Monitor %d: contrast readback failed: %s", monitor_id, e)
if static.get("input_source_supported"):
try:
src = mon.get_input_source()
dynamic["input_source"] = _enum_name(src, mc.InputSource if mc else None)
except Exception as e:
logger.debug("Monitor %d: input_source readback failed: %s", monitor_id, e)
if static.get("color_preset_supported"):
try:
preset = mon.get_color_preset()
dynamic["color_preset"] = _enum_name(preset, mc.ColorPreset if mc else None)
except Exception as e:
logger.debug("Monitor %d: color_preset readback failed: %s", monitor_id, e)
if static.get("picture_mode_supported"):
try:
current, _maximum = mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
dynamic["picture_mode_code"] = current
dynamic["picture_mode"] = PICTURE_MODE_LABELS.get(current, f"Mode {current}")
except Exception as e:
logger.debug("Monitor %d: picture_mode readback failed: %s", monitor_id, e)
return dynamic
def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list[MonitorInfo]:
"""List all connected monitors with their current state.
Args:
force_refresh: bypass the short TTL response cache.
rediscover: also drop the per-monitor static capability cache, so the
next probe re-runs DDC/CI capability discovery. Use after hot-plug
or when a monitor's reported capabilities change.
"""
global _monitor_cache, _cache_time global _monitor_cache, _cache_time
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL: if (
not force_refresh
and not rediscover
and _monitor_cache is not None
and (time.time() - _cache_time) < _CACHE_TTL
):
return _monitor_cache return _monitor_cache
sbc = _load_sbc() sbc = _load_sbc()
@@ -159,7 +373,12 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
info_list = sbc.list_monitors_info() info_list = sbc.list_monitors_info()
brightnesses = sbc.get_brightness() brightnesses = sbc.get_brightness()
# Get DDC/CI monitors for power state # Explicit rediscover wipes the whole cache; otherwise rely on stable
# per-monitor keys (manufacturer|model|edid_hash) so a hot-plug swap
# invalidates the entry for the missing monitor automatically.
if rediscover:
_static_cache.clear()
mc = _load_monitorcontrol() mc = _load_monitorcontrol()
ddc_monitors = [] ddc_monitors = []
if mc: if mc:
@@ -168,6 +387,9 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
except Exception: except Exception:
pass pass
import hashlib
seen_keys: set[tuple] = set()
for i, info in enumerate(info_list): for i, info in enumerate(info_list):
name = info.get("name", f"Monitor {i}") name = info.get("name", f"Monitor {i}")
model = info.get("model", "") model = info.get("model", "")
@@ -181,26 +403,66 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
edid = info.get("edid", "") edid = info.get("edid", "")
resolution = _parse_edid_resolution(edid) if edid else None resolution = _parse_edid_resolution(edid) if edid else None
# Read power state via DDC/CI # Stable cache key — EDID hash is unique per physical monitor.
power_on = True # Fall back to (manufacturer, model, serial-ish) when EDID is
# missing, then to the legacy index as a last resort.
if edid:
edid_hash = hashlib.blake2b(
edid.encode("utf-8") if isinstance(edid, str) else bytes(edid),
digest_size=8,
).hexdigest()
cache_key: tuple = ("edid", edid_hash)
elif manufacturer or model:
cache_key = ("mm", manufacturer, model, name)
else:
cache_key = ("idx", i)
seen_keys.add(cache_key)
static: dict = {}
dynamic: dict = {}
# Open the DDC handle ONCE; do static probe (if needed) + dynamic
# readback inside the same context. Opening the handle is the
# expensive part — keep both phases under one open.
if power_supported and i < len(ddc_monitors): if power_supported and i < len(ddc_monitors):
try: try:
with ddc_monitors[i] as mon: with ddc_monitors[i] as mon:
power_mode = mon.get_power_mode() if cache_key not in _static_cache:
power_on = power_mode == mc.PowerMode.on _static_cache[cache_key] = _probe_static_open(mon, mc, i)
except Exception: static = _static_cache[cache_key]
pass dynamic = _probe_dynamic_open(mon, mc, i, static)
except Exception as e:
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
static = _static_cache.get(cache_key, {})
monitors.append(MonitorInfo( monitors.append(MonitorInfo(
id=i, id=i,
name=name, name=name,
brightness=brightness, brightness=brightness,
power_supported=power_supported, power_supported=power_supported,
power_on=power_on, power_on=dynamic.get("power_on", True),
model=model, model=model,
manufacturer=manufacturer, manufacturer=manufacturer,
resolution=resolution, resolution=resolution,
contrast=dynamic.get("contrast"),
contrast_supported=static.get("contrast_supported", False),
input_source=dynamic.get("input_source"),
available_input_sources=static.get("available_input_sources", []),
input_source_supported=static.get("input_source_supported", False),
color_preset=dynamic.get("color_preset"),
available_color_presets=static.get("available_color_presets", []),
color_preset_supported=static.get("color_preset_supported", False),
picture_mode=dynamic.get("picture_mode"),
picture_mode_code=dynamic.get("picture_mode_code"),
available_picture_modes=static.get("available_picture_modes", []),
picture_mode_supported=static.get("picture_mode_supported", False),
)) ))
# Evict cache entries for monitors that disappeared from this scan so
# the next hot-plug of a different monitor with the same identity
# tuple (e.g. same model) doesn't hit a stale entry first.
for stale_key in list(_static_cache.keys()):
if stale_key not in seen_keys:
_static_cache.pop(stale_key, None)
except Exception as e: except Exception as e:
logger.error("Failed to enumerate monitors: %s", e) logger.error("Failed to enumerate monitors: %s", e)
@@ -234,9 +496,7 @@ def set_brightness(monitor_id: int, value: int) -> bool:
value = max(0, min(100, value)) value = max(0, min(100, value))
try: try:
sbc.set_brightness(value, display=monitor_id) sbc.set_brightness(value, display=monitor_id)
# Invalidate cache _invalidate_cache()
global _monitor_cache
_monitor_cache = None
return True return True
except Exception as e: except Exception as e:
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e) logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
@@ -262,10 +522,155 @@ def set_power(monitor_id: int, on: bool) -> bool:
else: else:
monitor.set_power_mode(mc.PowerMode.off_soft) monitor.set_power_mode(mc.PowerMode.off_soft)
# Invalidate cache _invalidate_cache()
global _monitor_cache
_monitor_cache = None
return True return True
except Exception as e: except Exception as e:
logger.error("Failed to set power for monitor %d: %s", monitor_id, e) logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
return False return False
def _verify_after_set(getter, expected, *, retries: int = 3, delay: float = 0.1) -> bool:
"""Poll a DDC/CI getter to confirm the monitor actually applied a write.
DDC/CI writes are fire-and-forget at the protocol level: a successful
send does not mean the monitor honored the value. Many monitors silently
drop writes for codes their firmware doesn't really implement (LG's
ColorPreset / Picture Mode are common offenders). Without this check the
API would report `success: true` while the monitor sat unchanged.
Compares both raw and `.value` forms so enum/int mismatches don't flag a
spurious failure.
"""
expected_int = getattr(expected, "value", expected)
for _ in range(retries):
time.sleep(delay)
try:
actual = getter()
except Exception:
continue
actual_int = getattr(actual, "value", actual)
if actual == expected or actual_int == expected_int:
return True
return False
def set_contrast(monitor_id: int, value: int) -> bool:
"""Set contrast for a specific monitor (0-100) via DDC/CI."""
mc = _load_monitorcontrol()
if mc is None:
return False
value = max(0, min(100, value))
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_contrast(value)
if not _verify_after_set(monitor.get_contrast, value):
logger.warning("Monitor %d: contrast %d not applied", monitor_id, value)
return False
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set contrast for monitor %d: %s", monitor_id, e)
return False
def set_input_source(monitor_id: int, source: str) -> bool:
"""Set the DDC/CI input source by enum name (e.g. 'HDMI1', 'DP1')."""
mc = _load_monitorcontrol()
if mc is None:
return False
try:
target = mc.InputSource[source]
except KeyError:
logger.error("Unknown input source: %s", source)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_input_source(target)
# Source switches can briefly disrupt the DDC/CI link; allow a
# longer settle window before declaring failure.
if not _verify_after_set(monitor.get_input_source, target, retries=5, delay=0.2):
logger.warning("Monitor %d: input source %s not applied", monitor_id, source)
return False
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set input source for monitor %d: %s", monitor_id, e)
return False
def set_color_preset(monitor_id: int, preset: str) -> bool:
"""Set the DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
mc = _load_monitorcontrol()
if mc is None:
return False
try:
target = mc.ColorPreset[preset]
except KeyError:
logger.error("Unknown color preset: %s", preset)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_color_preset(target)
if not _verify_after_set(monitor.get_color_preset, target):
logger.warning(
"Monitor %d: color preset %s not applied (monitor silently rejected)",
monitor_id, preset,
)
return False
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set color preset for monitor %d: %s", monitor_id, e)
return False
def set_picture_mode(monitor_id: int, code: int) -> bool:
"""Set the DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
mc = _load_monitorcontrol()
if mc is None:
return False
if not 0 <= code <= 255:
logger.error("Picture mode code %d out of range", code)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
return False
with ddc_monitors[monitor_id] as monitor:
monitor.vcp.set_vcp_feature(PICTURE_MODE_VCP, code)
# Raw VCP read returns (current, maximum) — only compare current.
def _read_picture_mode():
current, _ = monitor.vcp.get_vcp_feature(PICTURE_MODE_VCP)
return current
if not _verify_after_set(_read_picture_mode, code):
logger.warning(
"Monitor %d: picture mode code %d not applied (monitor silently rejected)",
monitor_id, code,
)
return False
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set picture mode for monitor %d: %s", monitor_id, e)
return False
def _invalidate_cache() -> None:
global _monitor_cache
_monitor_cache = None
+543
View File
@@ -0,0 +1,543 @@
"""Foreground (topmost) window/process tracking.
Reports the process that currently owns the foreground window, plus useful
metadata (window title, executable path, monitor index, whether the window
covers a full monitor, process start time).
All probes happen behind a short TTL cache so the WebSocket status poll and
per-entity HA polls don't pay the OS call cost on every tick.
Windows uses the Win32 API via ``ctypes`` (no extra dependency) and falls back
gracefully when individual probes fail. Linux/macOS implementations are
best-effort and return ``available=False`` when the required tooling is
missing, so the rest of the stack keeps working.
"""
from __future__ import annotations
import logging
import platform
import threading
import time
from dataclasses import asdict, dataclass, field
logger = logging.getLogger(__name__)
_CACHE_TTL = 0.5 # seconds — fast enough for WebSocket broadcast loop
@dataclass(frozen=True)
class ForegroundInfo:
"""Snapshot of the foreground window/process."""
available: bool
pid: int | None = None
process_name: str | None = None
executable_path: str | None = None
window_title: str | None = None
window_handle: int | None = None
is_fullscreen: bool = False
is_minimized: bool = False
monitor_id: int | None = None
monitor_geometry: dict[str, int] | None = None
window_geometry: dict[str, int] | None = None
started_at: float | None = None
platform: str = field(default_factory=lambda: platform.system())
error: str | None = None
# Populated only when the foreground process is a recognised web
# browser. ``browser_page_title`` is derived from the window title
# (suffix stripped); ``browser_url`` requires UIA to succeed.
is_browser: bool = False
browser_url: str | None = None
browser_page_title: str | None = None
def to_dict(self) -> dict:
return asdict(self)
_UNAVAILABLE = ForegroundInfo(available=False)
class _Cache:
"""Single-slot TTL cache shared across callers."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._value: ForegroundInfo | None = None
self._fetched_at: float = 0.0
def get(self, ttl: float, fetch) -> ForegroundInfo:
with self._lock:
now = time.monotonic()
if self._value is not None and (now - self._fetched_at) < ttl:
return self._value
# Fetch outside the lock — OS calls can take tens of ms.
value = fetch()
with self._lock:
self._value = value
self._fetched_at = time.monotonic()
return value
def invalidate(self) -> None:
with self._lock:
self._value = None
self._fetched_at = 0.0
_cache = _Cache()
# Win32 handles + signatures are declared once at module load (when running on
# Windows). The TTL cache fires this hundreds of times per minute; redoing the
# DLL load + ~10 argtype assignments per call was the largest chunk of probe
# cost. Keep these guarded behind a lazy init so non-Windows platforms don't
# pay the import.
_WIN32_INITIALIZED = False
_win32_user32 = None
_win32_kernel32 = None
_win32_psapi = None
def _init_win32_apis() -> None:
"""Declare ctypes argtypes/restype on every Win32 call we make.
CRITICAL: ctypes defaults to `c_int` (32-bit) for HANDLE/HWND/HMONITOR
which silently truncates 64-bit pointer values on x64 that corrupts the
handle so `CloseHandle()` can either fail or close the wrong kernel
object, and pointer-equality comparisons (monitor index lookup) miss.
"""
global _WIN32_INITIALIZED, _win32_user32, _win32_kernel32, _win32_psapi
if _WIN32_INITIALIZED:
return
import ctypes
import ctypes.wintypes as wt
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
psapi = ctypes.WinDLL("psapi", use_last_error=True)
user32.GetForegroundWindow.restype = wt.HWND
user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)]
user32.GetWindowThreadProcessId.restype = wt.DWORD
user32.GetWindowTextLengthW.argtypes = [wt.HWND]
user32.GetWindowTextLengthW.restype = ctypes.c_int
user32.GetWindowTextW.argtypes = [wt.HWND, wt.LPWSTR, ctypes.c_int]
user32.GetWindowTextW.restype = ctypes.c_int
user32.IsIconic.argtypes = [wt.HWND]
user32.IsIconic.restype = wt.BOOL
user32.GetWindowRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)]
user32.GetWindowRect.restype = wt.BOOL
user32.MonitorFromWindow.argtypes = [wt.HWND, wt.DWORD]
user32.MonitorFromWindow.restype = wt.HMONITOR
user32.GetMonitorInfoW.argtypes = [wt.HMONITOR, ctypes.c_void_p]
user32.GetMonitorInfoW.restype = wt.BOOL
kernel32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
kernel32.OpenProcess.restype = wt.HANDLE
kernel32.CloseHandle.argtypes = [wt.HANDLE]
kernel32.CloseHandle.restype = wt.BOOL
kernel32.QueryFullProcessImageNameW.argtypes = [
wt.HANDLE, wt.DWORD, wt.LPWSTR, ctypes.POINTER(wt.DWORD)
]
kernel32.QueryFullProcessImageNameW.restype = wt.BOOL
kernel32.GetProcessTimes.argtypes = [
wt.HANDLE,
ctypes.POINTER(wt.FILETIME),
ctypes.POINTER(wt.FILETIME),
ctypes.POINTER(wt.FILETIME),
ctypes.POINTER(wt.FILETIME),
]
kernel32.GetProcessTimes.restype = wt.BOOL
psapi.GetModuleFileNameExW.argtypes = [wt.HANDLE, wt.HMODULE, wt.LPWSTR, wt.DWORD]
psapi.GetModuleFileNameExW.restype = wt.DWORD
_win32_user32, _win32_kernel32, _win32_psapi = user32, kernel32, psapi
_WIN32_INITIALIZED = True
def _probe_windows() -> ForegroundInfo:
"""Probe foreground window state on Windows via Win32 API."""
import ctypes
import ctypes.wintypes as wt
_init_win32_apis()
user32 = _win32_user32
kernel32 = _win32_kernel32
psapi = _win32_psapi
hwnd = user32.GetForegroundWindow()
if not hwnd:
return ForegroundInfo(available=True, error="no foreground window")
# PID + window thread.
pid = wt.DWORD(0)
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
pid_val = int(pid.value) if pid.value else None
# Window title — Unicode.
length = user32.GetWindowTextLengthW(hwnd)
title_buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, title_buf, length + 1)
window_title = title_buf.value or None
# Minimized flag.
is_minimized = bool(user32.IsIconic(hwnd))
# Window rect (screen coords).
rect = wt.RECT()
window_geometry: dict[str, int] | None = None
if user32.GetWindowRect(hwnd, ctypes.byref(rect)):
window_geometry = {
"left": int(rect.left),
"top": int(rect.top),
"right": int(rect.right),
"bottom": int(rect.bottom),
"width": int(rect.right - rect.left),
"height": int(rect.bottom - rect.top),
}
# Monitor under the window + its geometry.
monitor_geometry: dict[str, int] | None = None
monitor_id: int | None = None
is_fullscreen = False
try:
MONITOR_DEFAULTTONEAREST = 2
class MONITORINFO(ctypes.Structure):
_fields_ = [
("cbSize", wt.DWORD),
("rcMonitor", wt.RECT),
("rcWork", wt.RECT),
("dwFlags", wt.DWORD),
]
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
if hmon:
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(mi)
if user32.GetMonitorInfoW(hmon, ctypes.byref(mi)):
monitor_geometry = {
"left": int(mi.rcMonitor.left),
"top": int(mi.rcMonitor.top),
"right": int(mi.rcMonitor.right),
"bottom": int(mi.rcMonitor.bottom),
"width": int(mi.rcMonitor.right - mi.rcMonitor.left),
"height": int(mi.rcMonitor.bottom - mi.rcMonitor.top),
}
# Fullscreen heuristic: window rect equals monitor rect AND
# not minimized. Many media players (VLC, browser fullscreen)
# set themselves to exactly the monitor bounds.
if window_geometry and not is_minimized:
is_fullscreen = (
window_geometry["left"] == monitor_geometry["left"]
and window_geometry["top"] == monitor_geometry["top"]
and window_geometry["right"] == monitor_geometry["right"]
and window_geometry["bottom"] == monitor_geometry["bottom"]
)
# Resolve monitor index by enumerating displays in order. Coerce
# both the foreground hmon and the per-enum hmon to int so the
# equality compare uses 64-bit values consistently regardless of
# how ctypes represents the handle internally.
try:
indexed: list[int] = []
def _cb(hm, _hdc, _rect, _data):
indexed.append(int(hm) if hm else 0)
return True
MONITORENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_int,
wt.HMONITOR,
wt.HDC,
ctypes.POINTER(wt.RECT),
wt.LPARAM,
)
user32.EnumDisplayMonitors.argtypes = [
wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM
]
user32.EnumDisplayMonitors.restype = wt.BOOL
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(_cb), 0)
target = int(hmon) if hmon else 0
if target and target in indexed:
monitor_id = indexed.index(target)
except Exception as e:
logger.debug("Monitor index resolution failed: %s", e)
except Exception as e:
logger.debug("Monitor info probe failed: %s", e)
# Process executable path + start time.
executable_path: str | None = None
process_name: str | None = None
started_at: float | None = None
if pid_val:
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
h_proc = kernel32.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, pid_val
)
if h_proc:
try:
# Image filename — full path. QueryFullProcessImageNameW works
# across 32/64-bit boundaries, unlike GetModuleFileNameExW.
buf = ctypes.create_unicode_buffer(1024)
size = wt.DWORD(len(buf))
if kernel32.QueryFullProcessImageNameW(
h_proc, 0, buf, ctypes.byref(size)
):
executable_path = buf.value or None
else:
# Fallback via psapi. Return value is the length copied
# into the buffer (0 on failure); ignoring it would leave
# `executable_path` as an empty string from the freshly
# allocated buffer instead of None.
written = psapi.GetModuleFileNameExW(h_proc, None, buf, len(buf))
if written:
executable_path = buf.value or None
else:
logger.debug(
"QueryFullProcessImageNameW + psapi fallback both "
"failed for pid=%s (err=%d)",
pid_val,
ctypes.get_last_error(),
)
if executable_path:
import os
process_name = os.path.basename(executable_path)
# Process creation time (FILETIME, 100ns ticks since 1601).
creation = wt.FILETIME()
exit_t = wt.FILETIME()
kernel_t = wt.FILETIME()
user_t = wt.FILETIME()
if kernel32.GetProcessTimes(
h_proc,
ctypes.byref(creation),
ctypes.byref(exit_t),
ctypes.byref(kernel_t),
ctypes.byref(user_t),
):
ticks = (creation.dwHighDateTime << 32) | creation.dwLowDateTime
# Convert to Unix epoch seconds (1601-01-01 → 1970-01-01).
if ticks:
started_at = (ticks - 116444736000000000) / 10_000_000
finally:
kernel32.CloseHandle(h_proc)
return ForegroundInfo(
available=True,
pid=pid_val,
process_name=process_name,
executable_path=executable_path,
window_title=window_title,
window_handle=int(hwnd) if hwnd else None,
is_fullscreen=is_fullscreen,
is_minimized=is_minimized,
monitor_id=monitor_id,
monitor_geometry=monitor_geometry,
window_geometry=window_geometry,
started_at=started_at,
)
def _probe_macos() -> ForegroundInfo:
"""Best-effort probe on macOS via AppKit (PyObjC).
Returns ``available=False`` when PyObjC is not installed we don't take
a hard dependency on it because the typical macOS install path uses pip
+ the standalone wheel.
"""
try:
from AppKit import NSWorkspace # type: ignore
from Quartz import ( # type: ignore
CGWindowListCopyWindowInfo,
kCGNullWindowID,
kCGWindowListOptionOnScreenOnly,
)
except Exception:
return ForegroundInfo(available=False, error="AppKit/Quartz not available")
try:
ws = NSWorkspace.sharedWorkspace()
app = ws.frontmostApplication()
if app is None:
return ForegroundInfo(available=True, error="no frontmost app")
pid = int(app.processIdentifier())
process_name = str(app.localizedName() or "")
bundle_url = app.bundleURL()
executable_path = str(bundle_url.path()) if bundle_url else None
started_at = None
launch_date = app.launchDate()
if launch_date is not None:
started_at = float(launch_date.timeIntervalSince1970())
# Window title — frontmost on-screen window owned by this PID.
window_title: str | None = None
try:
windows = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
)
for w in windows or []:
if int(w.get("kCGWindowOwnerPID", -1)) == pid:
name = w.get("kCGWindowName")
if name:
window_title = str(name)
break
except Exception as e:
logger.debug("CGWindowListCopyWindowInfo failed: %s", e)
return ForegroundInfo(
available=True,
pid=pid,
process_name=process_name,
executable_path=executable_path,
window_title=window_title,
started_at=started_at,
)
except Exception as e:
logger.debug("macOS foreground probe failed: %s", e)
return ForegroundInfo(available=False, error=str(e))
def _probe_linux() -> ForegroundInfo:
"""Best-effort probe on Linux via Xlib (X11 only).
Wayland sessions intentionally hide window/process info from unprivileged
clients, so this returns ``available=False`` on Wayland. The caller still
gets a structured response and can render "unavailable" in the UI.
"""
import os
if os.environ.get("WAYLAND_DISPLAY"):
return ForegroundInfo(
available=False, error="Wayland session — foreground probe unavailable"
)
try:
from Xlib import X, display # type: ignore # noqa: F401
except Exception:
return ForegroundInfo(available=False, error="python-xlib not installed")
try:
d = display.Display()
root = d.screen().root
NET_ACTIVE_WINDOW = d.intern_atom("_NET_ACTIVE_WINDOW")
NET_WM_PID = d.intern_atom("_NET_WM_PID")
NET_WM_NAME = d.intern_atom("_NET_WM_NAME")
UTF8_STRING = d.intern_atom("UTF8_STRING")
active = root.get_full_property(NET_ACTIVE_WINDOW, X.AnyPropertyType)
if not active or not active.value:
return ForegroundInfo(available=True, error="no active window")
win_id = int(active.value[0])
win = d.create_resource_object("window", win_id)
pid_prop = win.get_full_property(NET_WM_PID, X.AnyPropertyType)
pid_val = int(pid_prop.value[0]) if pid_prop and pid_prop.value else None
name_prop = win.get_full_property(NET_WM_NAME, UTF8_STRING)
window_title = (
name_prop.value.decode("utf-8", "replace") if name_prop and name_prop.value else None
)
process_name: str | None = None
executable_path: str | None = None
started_at: float | None = None
if pid_val:
try:
exe = os.readlink(f"/proc/{pid_val}/exe")
executable_path = exe
process_name = os.path.basename(exe)
except OSError as e:
logger.debug("readlink /proc/%d/exe failed: %s", pid_val, e)
try:
started_at = os.stat(f"/proc/{pid_val}").st_ctime
except OSError as e:
logger.debug("stat /proc/%d failed: %s", pid_val, e)
return ForegroundInfo(
available=True,
pid=pid_val,
process_name=process_name,
executable_path=executable_path,
window_title=window_title,
window_handle=win_id,
started_at=started_at,
)
except Exception as e:
logger.debug("Linux foreground probe failed: %s", e)
return ForegroundInfo(available=False, error=str(e))
def _enrich_browser(info: ForegroundInfo) -> ForegroundInfo:
"""If ``info`` describes a focused browser, attach URL + page title.
The UIA lookup is wrapped in its own try/except so a failure here can't
take down the rest of the foreground probe.
"""
try:
from . import browser_url_service as bus
except Exception as e:
logger.debug("browser_url_service unavailable: %s", e)
return info
if not info.available or not bus.is_browser_process(info.process_name):
return info
try:
page = bus.get_browser_page(
hwnd=info.window_handle,
process_name=info.process_name,
window_title=info.window_title,
)
except Exception as e:
logger.debug("Browser URL enrichment failed: %s", e)
return info
# ``dataclasses.replace`` keeps the frozen-dataclass contract.
from dataclasses import replace
return replace(
info,
is_browser=True,
browser_url=page.url,
browser_page_title=page.page_title,
)
def _probe() -> ForegroundInfo:
system = platform.system()
try:
if system == "Windows":
info = _probe_windows()
elif system == "Darwin":
info = _probe_macos()
elif system == "Linux":
info = _probe_linux()
else:
return ForegroundInfo(
available=False, error=f"unsupported platform: {system}"
)
return _enrich_browser(info)
except Exception as e:
logger.warning("Foreground probe crashed: %s", e)
return ForegroundInfo(available=False, error=str(e))
def get_foreground_info(force_refresh: bool = False) -> ForegroundInfo:
"""Return the current foreground window/process snapshot.
Args:
force_refresh: bypass the short TTL cache. WebSocket broadcast loop
should leave this False; the REST endpoint accepts ?refresh=1
for callers that want a fresh probe.
"""
if force_refresh:
_cache.invalidate()
return _cache.get(_CACHE_TTL, _probe)
def reset_cache() -> None:
"""Reset the cache. Useful in tests."""
_cache.invalidate()
@@ -2,6 +2,7 @@
import json import json
import logging import logging
import re
import urllib.error import urllib.error
import urllib.request import urllib.request
from typing import Optional from typing import Optional
@@ -15,6 +16,11 @@ _DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
_DEFAULT_OWNER = "alexei.dolgolyov" _DEFAULT_OWNER = "alexei.dolgolyov"
_DEFAULT_REPO = "media-player-server" _DEFAULT_REPO = "media-player-server"
# Restrictive tag whitelist — prevents a hostile Gitea response (or MITM) from
# injecting `..`, slashes, or URL-altering characters into the release URL we
# broadcast to clients. SemVer + pre-release suffix only.
_TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+(?:[\w.\-+]{0,32})?$")
class GiteaReleaseProvider(ReleaseProvider): class GiteaReleaseProvider(ReleaseProvider):
"""Fetches the latest release from a Gitea repository.""" """Fetches the latest release from a Gitea repository."""
@@ -53,6 +59,9 @@ class GiteaReleaseProvider(ReleaseProvider):
continue continue
tag = release.get("tag_name", "") tag = release.get("tag_name", "")
if not isinstance(tag, str) or not _TAG_RE.match(tag):
logger.warning("Rejecting malformed release tag from upstream: %r", tag)
continue
version = tag.lstrip("v") version = tag.lstrip("v")
if not version: if not version:
continue continue
+144 -72
View File
@@ -2,14 +2,23 @@
import asyncio import asyncio
import logging import logging
import os
import subprocess import subprocess
import threading
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import unquote, urlparse
from ..models import MediaState, MediaStatus from ..models import MediaState, MediaStatus
from .media_controller import MediaController from .media_controller import MediaController
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cap remote artwork downloads so a hostile / huge Spotify image can't
# blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable
# upper bound that also covers loss-less PNGs.
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0 # seconds
# Linux-specific imports # Linux-specific imports
try: try:
import dbus import dbus
@@ -35,13 +44,54 @@ class LinuxMediaController(MediaController):
"Linux media control requires dbus-python package. " "Linux media control requires dbus-python package. "
"Install with: sudo apt-get install python3-dbus" "Install with: sudo apt-get install python3-dbus"
) )
DBusGMainLoop(set_as_default=True) # The session-bus connection is deferred until first use. Connecting
self._bus = dbus.SessionBus() # in __init__ raised during app startup whenever the user's session
# bus wasn't ready yet — common under systemd (service starts
# before logind set up /run/user/<uid>/bus), under SSH-without-X11,
# and in headless CI. Failing here killed the whole lifespan; now
# MPRIS calls return "idle" until the bus appears, and other
# endpoints (health, scripts, browser, …) keep working.
self._bus_lock = threading.Lock()
self._bus = None # type: ignore[assignment]
self._bus_init_logged = False
# Cached art bytes keyed by the mpris:artUrl currently in flight.
# Lock guards the swap from the status thread vs the artwork handler.
self._art_lock = threading.Lock()
self._art_url: Optional[str] = None
self._art_bytes: Optional[bytes] = None
def _get_bus(self):
"""Lazily connect to the session bus; returns None if unavailable."""
if self._bus is not None:
return self._bus
with self._bus_lock:
if self._bus is not None:
return self._bus
try:
DBusGMainLoop(set_as_default=True)
self._bus = dbus.SessionBus()
logger.info("Connected to D-Bus session bus")
return self._bus
except Exception as e:
# Log once at INFO to avoid log spam if every status poll fails.
if not self._bus_init_logged:
logger.info(
"D-Bus session bus not available (%s). "
"MPRIS calls will return 'idle' until DBUS_SESSION_BUS_ADDRESS"
" is set and the bus is reachable. Under systemd, ensure"
" `loginctl enable-linger <user>` is set.",
e,
)
self._bus_init_logged = True
return None
def _get_active_player(self) -> Optional[str]: def _get_active_player(self) -> Optional[str]:
"""Find an active MPRIS media player on the bus.""" """Find an active MPRIS media player on the bus."""
bus = self._get_bus()
if bus is None:
return None
try: try:
bus_names = self._bus.list_names() bus_names = bus.list_names()
mpris_players = [ mpris_players = [
name for name in bus_names if name.startswith(self.MPRIS_PREFIX) name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
] ]
@@ -151,22 +201,19 @@ class LinuxMediaController(MediaController):
logger.error(f"Failed to toggle mute: {e}") logger.error(f"Failed to toggle mute: {e}")
return False return False
async def get_status(self) -> MediaStatus: def _sync_get_status(self) -> MediaStatus:
"""Get current media playback status.""" """Synchronous status read (called from a worker thread)."""
status = MediaStatus() status = MediaStatus()
# Get system volume
volume, muted = self._get_volume_pulseaudio() volume, muted = self._get_volume_pulseaudio()
status.volume = volume status.volume = volume
status.muted = muted status.muted = muted
# Get active player
player_name = self._get_active_player() player_name = self._get_active_player()
if player_name is None: if player_name is None:
status.state = MediaState.IDLE status.state = MediaState.IDLE
return status return status
# Get playback status
playback_status = self._get_property(player_name, "PlaybackStatus") playback_status = self._get_property(player_name, "PlaybackStatus")
if playback_status == "Playing": if playback_status == "Playing":
status.state = MediaState.PLAYING status.state = MediaState.PLAYING
@@ -177,114 +224,78 @@ class LinuxMediaController(MediaController):
else: else:
status.state = MediaState.IDLE status.state = MediaState.IDLE
# Get metadata
metadata = self._get_property(player_name, "Metadata") metadata = self._get_property(player_name, "Metadata")
if metadata: if metadata:
status.title = str(metadata.get("xesam:title", "")) or None status.title = str(metadata.get("xesam:title", "")) or None
artists = metadata.get("xesam:artist", []) artists = metadata.get("xesam:artist", [])
if artists: if artists:
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists) status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
status.album = str(metadata.get("xesam:album", "")) or None status.album = str(metadata.get("xesam:album", "")) or None
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None art_url = str(metadata.get("mpris:artUrl", "")) or None
status.album_art_url = art_url
# Duration in microseconds # Invalidate cached bytes when the track changes. Real fetch
# happens lazily in get_album_art() so the status hot path
# never blocks on HTTP.
with self._art_lock:
if art_url != self._art_url:
self._art_url = art_url
self._art_bytes = None
length = metadata.get("mpris:length", 0) length = metadata.get("mpris:length", 0)
if length: if length:
status.duration = int(length) / 1_000_000 status.duration = int(length) / 1_000_000
# Get position (in microseconds)
position = self._get_property(player_name, "Position") position = self._get_property(player_name, "Position")
if position is not None: if position is not None:
status.position = int(position) / 1_000_000 status.position = int(position) / 1_000_000
# Get source name
status.source = player_name.replace(self.MPRIS_PREFIX, "") status.source = player_name.replace(self.MPRIS_PREFIX, "")
return status return status
async def play(self) -> bool: async def get_status(self) -> MediaStatus:
"""Resume playback.""" """Get current media playback status (off the event loop)."""
# pactl + DBus calls each take 5-100ms on a Pi and would block every
# other coroutine on the server. Run them in a worker thread.
return await asyncio.to_thread(self._sync_get_status)
def _call_player(self, method_name: str) -> bool:
player_name = self._get_active_player() player_name = self._get_active_player()
if player_name is None: if player_name is None:
return False return False
try: try:
player = self._get_player_interface(player_name) player = self._get_player_interface(player_name)
player.Play() getattr(player, method_name)()
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to play: {e}") logger.error(f"Failed to call player.{method_name}: {e}")
return False return False
async def play(self) -> bool:
return await asyncio.to_thread(self._call_player, "Play")
async def pause(self) -> bool: async def pause(self) -> bool:
"""Pause playback.""" return await asyncio.to_thread(self._call_player, "Pause")
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Pause()
return True
except Exception as e:
logger.error(f"Failed to pause: {e}")
return False
async def stop(self) -> bool: async def stop(self) -> bool:
"""Stop playback.""" return await asyncio.to_thread(self._call_player, "Stop")
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Stop()
return True
except Exception as e:
logger.error(f"Failed to stop: {e}")
return False
async def next_track(self) -> bool: async def next_track(self) -> bool:
"""Skip to next track.""" return await asyncio.to_thread(self._call_player, "Next")
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Next()
return True
except Exception as e:
logger.error(f"Failed to skip next: {e}")
return False
async def previous_track(self) -> bool: async def previous_track(self) -> bool:
"""Go to previous track.""" return await asyncio.to_thread(self._call_player, "Previous")
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Previous()
return True
except Exception as e:
logger.error(f"Failed to skip previous: {e}")
return False
async def set_volume(self, volume: int) -> bool: async def set_volume(self, volume: int) -> bool:
"""Set system volume.""" return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
return self._set_volume_pulseaudio(volume)
async def toggle_mute(self) -> bool: async def toggle_mute(self) -> bool:
"""Toggle mute state.""" return await asyncio.to_thread(self._toggle_mute_pulseaudio)
return self._toggle_mute_pulseaudio()
async def seek(self, position: float) -> bool: def _sync_seek(self, position: float) -> bool:
"""Seek to position in seconds."""
player_name = self._get_active_player() player_name = self._get_active_player()
if player_name is None: if player_name is None:
return False return False
try: try:
player = self._get_player_interface(player_name) player = self._get_player_interface(player_name)
# MPRIS expects position in microseconds
player.SetPosition( player.SetPosition(
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"), self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
int(position * 1_000_000), int(position * 1_000_000),
@@ -294,6 +305,9 @@ class LinuxMediaController(MediaController):
logger.error(f"Failed to seek: {e}") logger.error(f"Failed to seek: {e}")
return False return False
async def seek(self, position: float) -> bool:
return await asyncio.to_thread(self._sync_seek, position)
async def open_file(self, file_path: str) -> bool: async def open_file(self, file_path: str) -> bool:
"""Open a media file with the default system player (Linux). """Open a media file with the default system player (Linux).
@@ -317,3 +331,61 @@ class LinuxMediaController(MediaController):
except Exception as e: except Exception as e:
logger.error(f"Failed to open file {file_path}: {e}") logger.error(f"Failed to open file {file_path}: {e}")
return False return False
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
"""Resolve an ``mpris:artUrl`` to raw bytes (file://, http(s)://).
Other schemes (data:, ftp:, ) are rejected we only support the
two cases real-world MPRIS providers use. The HTTP path is capped
at _MAX_ART_BYTES and the file path is read with a size guard so a
symlink to /dev/zero can't OOM the server.
"""
try:
parsed = urlparse(url)
except ValueError:
return None
scheme = parsed.scheme.lower()
if scheme == "file":
path = unquote(parsed.path)
try:
size = os.stat(path).st_size
if size <= 0 or size > _MAX_ART_BYTES:
return None
with open(path, "rb") as f:
return f.read(_MAX_ART_BYTES)
except OSError as e:
logger.debug("Could not read local art %s: %s", path, e)
return None
if scheme in ("http", "https"):
import urllib.error
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
try:
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
# Cap reads to defend against unbounded responses.
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
logger.debug("Could not fetch remote art %s: %s", url, e)
return None
logger.debug("Unsupported art URL scheme: %s", scheme)
return None
async def get_album_art(self) -> Optional[bytes]:
"""Return cached MPRIS art, fetching on first access per track."""
with self._art_lock:
url = self._art_url
cached = self._art_bytes
if cached is not None:
return cached
if not url:
return None
data = await asyncio.to_thread(self._fetch_art_sync, url)
# Store even on None so we don't re-hammer a 404 every second.
with self._art_lock:
if url == self._art_url:
self._art_bytes = data
return data
+66 -3
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import subprocess import subprocess
import threading
from typing import Optional from typing import Optional
from ..models import MediaState, MediaStatus from ..models import MediaState, MediaStatus
@@ -10,10 +11,20 @@ from .media_controller import MediaController
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cap remote artwork downloads (Spotify's artwork url is http(s)://).
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0 # seconds
class MacOSMediaController(MediaController): class MacOSMediaController(MediaController):
"""Media controller for macOS using osascript and system commands.""" """Media controller for macOS using osascript and system commands."""
def __init__(self) -> None:
# Cached art bytes keyed by the active art URL.
self._art_lock = threading.Lock()
self._art_url: Optional[str] = None
self._art_bytes: Optional[bytes] = None
def _run_osascript(self, script: str) -> Optional[str]: def _run_osascript(self, script: str) -> Optional[str]:
"""Run an AppleScript and return the output.""" """Run an AppleScript and return the output."""
try: try:
@@ -193,12 +204,60 @@ class MacOSMediaController(MediaController):
status.album = info.get("album") status.album = info.get("album")
status.duration = info.get("duration") status.duration = info.get("duration")
status.position = info.get("position") status.position = info.get("position")
status.album_art_url = info.get("art_url") art_url = info.get("art_url")
status.album_art_url = art_url
# Track changes invalidate the cached image bytes — actual
# fetch happens lazily in get_album_art().
with self._art_lock:
if art_url != self._art_url:
self._art_url = art_url
self._art_bytes = None
else: else:
status.state = MediaState.IDLE status.state = MediaState.IDLE
return status return status
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
"""Resolve a Spotify/Music art URL (http(s)://) to bytes.
File-scheme URLs aren't expected on macOS (AppleScript apps return
artwork as remote URLs), so only http(s) is supported.
"""
try:
from urllib.parse import urlparse
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme.lower() not in ("http", "https"):
return None
import urllib.error
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
try:
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
logger.debug("Could not fetch macOS art %s: %s", url, e)
return None
async def get_album_art(self) -> Optional[bytes]:
"""Return cached art bytes, fetching once per track URL."""
with self._art_lock:
url = self._art_url
cached = self._art_bytes
if cached is not None:
return cached
if not url:
return None
data = await asyncio.to_thread(self._fetch_art_sync, url)
with self._art_lock:
if url == self._art_url:
self._art_bytes = data
return data
async def play(self) -> bool: async def play(self) -> bool:
"""Resume playback using media key simulation.""" """Resume playback using media key simulation."""
# Use system media key # Use system media key
@@ -264,8 +323,12 @@ class MacOSMediaController(MediaController):
async def set_volume(self, volume: int) -> bool: async def set_volume(self, volume: int) -> bool:
"""Set system volume.""" """Set system volume."""
result = self._run_osascript(f"set volume output volume {volume}") # osascript returns empty string on success and None on failure (the
return result is not None or True # osascript returns empty on success # _run_osascript helper catches subprocess errors). The previous
# `result is not None or True` always returned True regardless of
# outcome — surface real failures so the route can return 503.
result = self._run_osascript(f"set volume output volume {int(volume)}")
return result is not None
async def toggle_mute(self) -> bool: async def toggle_mute(self) -> bool:
"""Toggle mute state.""" """Toggle mute state."""
@@ -106,3 +106,12 @@ class MediaController(ABC):
True if successful, False otherwise True if successful, False otherwise
""" """
pass pass
async def get_album_art(self) -> bytes | None:
"""Return the current album art bytes, or ``None`` when unavailable.
Default impl returns ``None`` controllers that can produce art
(Windows via SMTC thumbnail, Linux via mpris:artUrl, macOS via the
Spotify/Music artwork-url field) override this.
"""
return None
+95
View File
@@ -0,0 +1,95 @@
"""In-process token-bucket rate limiter.
Light enough for a single-process app: one dict keyed by ``(bucket, peer)``
guarded by a thread lock. No extra dependency, no Redis. Good enough for
defeating credential-stuffing and runaway clients on a LAN; not a substitute
for an upstream WAF in a public deployment.
Buckets:
auth failed-auth attempts, 5/min/peer (used in auth middleware)
execute script + callback execute calls, 10/min/peer (LAN-friendly)
default generic POST/DELETE writes, 60/min/peer
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass
class BucketConfig:
capacity: float # max tokens (= burst size)
refill_per_sec: float # tokens added per second
# Defaults — tuned for "trusted LAN" use; operator can override via Settings.
BUCKETS: dict[str, BucketConfig] = {
"auth": BucketConfig(capacity=5, refill_per_sec=5 / 60), # 5/min
"execute": BucketConfig(capacity=10, refill_per_sec=10 / 60), # 10/min
"default": BucketConfig(capacity=60, refill_per_sec=60 / 60), # 60/min
}
_state: dict[tuple[str, str], tuple[float, float]] = {}
_lock = threading.Lock()
_LAST_CLEANUP = 0.0
def _evict_stale_locked(now: float) -> None:
"""Drop entries whose buckets are full (= idle for capacity / refill seconds)."""
global _LAST_CLEANUP
if now - _LAST_CLEANUP < 60:
return
_LAST_CLEANUP = now
stale = []
for key, (tokens, last) in _state.items():
bucket = BUCKETS.get(key[0])
if bucket is None:
continue
if tokens >= bucket.capacity and (now - last) > 3600:
stale.append(key)
for key in stale:
_state.pop(key, None)
def check(bucket: str, peer: str) -> tuple[bool, Optional[float]]:
"""Try to consume one token from ``(bucket, peer)``.
Returns:
(allowed, retry_after_seconds). When allowed=True retry_after is None.
When allowed=False, retry_after is the seconds to wait for one more token.
"""
cfg = BUCKETS.get(bucket) or BUCKETS["default"]
now = time.monotonic()
with _lock:
_evict_stale_locked(now)
tokens, last = _state.get((bucket, peer), (cfg.capacity, now))
elapsed = max(0.0, now - last)
tokens = min(cfg.capacity, tokens + elapsed * cfg.refill_per_sec)
if tokens >= 1:
tokens -= 1
_state[(bucket, peer)] = (tokens, now)
return True, None
deficit = 1 - tokens
retry = deficit / cfg.refill_per_sec if cfg.refill_per_sec > 0 else 60
_state[(bucket, peer)] = (tokens, now)
return False, retry
def get_peer(request) -> str:
"""Best-effort peer identifier from a Starlette request.
Honors X-Forwarded-For (only when settings.proxy_headers is True, which is
already enforced by uvicorn's middleware) so a reverse-proxied install
still rate-limits per real client.
"""
client = getattr(request, "client", None)
if client and client.host:
return client.host
return "unknown"
+17 -6
View File
@@ -26,12 +26,23 @@ class ThumbnailService:
def get_cache_dir() -> Path: def get_cache_dir() -> Path:
"""Get the thumbnail cache directory path. """Get the thumbnail cache directory path.
Returns: Returns user-writable platform cache dir so installs under
Path to the cache directory (project-local). ``%PROGRAMFILES%`` / ``/opt`` work without elevated permissions.
Mirrors the platform branching of ``config.get_config_dir``.
""" """
# Store cache in project directory: media-server/.cache/thumbnails/ import os
project_root = Path(__file__).parent.parent.parent
cache_dir = project_root / ".cache" / "thumbnails" if os.name == "nt":
# %LOCALAPPDATA% so the cache survives roaming-profile sync.
base = Path(os.environ.get("LOCALAPPDATA")
or os.environ.get("APPDATA")
or Path.home() / "AppData" / "Local")
cache_dir = base / "media-server" / "cache" / "thumbnails"
else:
# XDG_CACHE_HOME convention; falls back to ~/.cache.
xdg = os.environ.get("XDG_CACHE_HOME")
base = Path(xdg) if xdg else Path.home() / ".cache"
cache_dir = base / "media-server" / "thumbnails"
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir return cache_dir
@@ -321,7 +332,7 @@ class ThumbnailService:
if suffix in AUDIO_EXTENSIONS: if suffix in AUDIO_EXTENSIONS:
# Audio files - run in executor (sync operation) # Audio files - run in executor (sync operation)
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
thumbnail_data = await loop.run_in_executor( thumbnail_data = await loop.run_in_executor(
None, None,
ThumbnailService.generate_audio_thumbnail, ThumbnailService.generate_audio_thumbnail,
+95 -8
View File
@@ -19,6 +19,9 @@ class ConnectionManager:
self._active_connections: set[WebSocket] = set() self._active_connections: set[WebSocket] = set()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._last_status: dict[str, Any] | None = None self._last_status: dict[str, Any] | None = None
self._last_foreground: dict[str, Any] | None = None
self._foreground_poll_interval: float = 1.0
self._last_foreground_poll: float = 0.0
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
self._broadcast_task: asyncio.Task | None = None self._broadcast_task: asyncio.Task | None = None
self._poll_interval: float = 0.5 # Internal poll interval for change detection self._poll_interval: float = 0.5 # Internal poll interval for change detection
@@ -30,9 +33,15 @@ class ConnectionManager:
self._audio_task: asyncio.Task | None = None self._audio_task: asyncio.Task | None = None
self._audio_analyzer = None self._audio_analyzer = None
async def connect(self, websocket: WebSocket) -> None: async def connect(self, websocket: WebSocket, already_accepted: bool = False) -> None:
"""Accept a new WebSocket connection.""" """Accept a new WebSocket connection.
await websocket.accept()
``already_accepted=True`` is for callers that needed to call
``websocket.accept(subprotocol=...)`` themselves (token-via-subprotocol
auth path).
"""
if not already_accepted:
await websocket.accept()
async with self._lock: async with self._lock:
self._active_connections.add(websocket) self._active_connections.add(websocket)
logger.info( logger.info(
@@ -54,6 +63,18 @@ class ConnectionManager:
except Exception as e: except Exception as e:
logger.debug("Failed to send initial status: %s", e) logger.debug("Failed to send initial status: %s", e)
# Push a fresh foreground snapshot on connect so the UI can render
# the tile immediately instead of waiting for the next change.
try:
from .foreground_service import get_foreground_info
fg = await asyncio.to_thread(get_foreground_info)
fg_dict = fg.to_dict()
self._last_foreground = fg_dict
await websocket.send_json({"type": "foreground", "data": fg_dict})
except Exception as e:
logger.debug("Failed to send initial foreground snapshot: %s", e)
async def disconnect(self, websocket: WebSocket) -> None: async def disconnect(self, websocket: WebSocket) -> None:
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber.""" """Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
should_stop = False should_stop = False
@@ -70,16 +91,27 @@ class ConnectionManager:
) )
async def broadcast(self, message: dict[str, Any]) -> None: async def broadcast(self, message: dict[str, Any]) -> None:
"""Broadcast a message to all connected clients concurrently.""" """Broadcast a message to all connected clients concurrently.
The payload is serialized once and pushed via ``send_text`` to every
client, instead of having Starlette/Pydantic encode it N times via
``send_json``.
"""
async with self._lock: async with self._lock:
connections = list(self._active_connections) connections = list(self._active_connections)
if not connections: if not connections:
return return
try:
payload = json.dumps(message, default=str)
except (TypeError, ValueError) as e:
logger.error("Failed to encode broadcast message: %s", e)
return
async def _send(ws: WebSocket) -> WebSocket | None: async def _send(ws: WebSocket) -> WebSocket | None:
try: try:
await ws.send_json(message) await ws.send_text(payload)
return None return None
except Exception as e: except Exception as e:
logger.debug("Failed to send to client: %s", e) logger.debug("Failed to send to client: %s", e)
@@ -104,6 +136,35 @@ class ConnectionManager:
await self.broadcast(message) await self.broadcast(message)
logger.info("Broadcast sent: links_changed") logger.info("Broadcast sent: links_changed")
def foreground_changed(
self, old: dict[str, Any] | None, new: dict[str, Any]
) -> bool:
"""Detect a meaningful change in the foreground process snapshot.
The probe also returns ``window_geometry`` which jitters on every
pixel of cursor drag comparing the whole dict would flood clients.
We only diff the fields a user (or HA automation) would actually act
on. ``window_geometry``/``monitor_geometry``/``started_at`` are still
delivered in the payload, but they don't drive broadcast cadence.
"""
if old is None:
return True
diff_fields = (
"pid",
"process_name",
"executable_path",
"window_title",
"is_fullscreen",
"is_minimized",
"monitor_id",
"available",
"error",
)
for f in diff_fields:
if old.get(f) != new.get(f):
return True
return False
async def subscribe_visualizer(self, websocket: WebSocket) -> None: async def subscribe_visualizer(self, websocket: WebSocket) -> None:
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber.""" """Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
should_start = False should_start = False
@@ -129,7 +190,7 @@ class ConnectionManager:
async def _maybe_start_capture(self) -> None: async def _maybe_start_capture(self) -> None:
"""Start audio capture if not already running (called on first subscriber).""" """Start audio capture if not already running (called on first subscriber)."""
if self._audio_analyzer and not self._audio_analyzer.running: if self._audio_analyzer and not self._audio_analyzer.running:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
started = await loop.run_in_executor(None, self._audio_analyzer.start) started = await loop.run_in_executor(None, self._audio_analyzer.start)
if started: if started:
logger.info("Audio capture started (first subscriber)") logger.info("Audio capture started (first subscriber)")
@@ -139,7 +200,7 @@ class ConnectionManager:
async def _maybe_stop_capture(self) -> None: async def _maybe_stop_capture(self) -> None:
"""Stop audio capture if running (called when last subscriber leaves).""" """Stop audio capture if running (called when last subscriber leaves)."""
if self._audio_analyzer and self._audio_analyzer.running: if self._audio_analyzer and self._audio_analyzer.running:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._audio_analyzer.stop) await loop.run_in_executor(None, self._audio_analyzer.stop)
logger.info("Audio capture stopped (no subscribers)") logger.info("Audio capture stopped (no subscribers)")
@@ -171,7 +232,7 @@ class ConnectionManager:
idle_interval = 1.0 / max(1, settings.visualizer_fps) idle_interval = 1.0 / max(1, settings.visualizer_fps)
# Bounded wait so we still notice subscribe/unsubscribe transitions. # Bounded wait so we still notice subscribe/unsubscribe transitions.
wake_timeout = max(0.05, idle_interval) wake_timeout = max(0.05, idle_interval)
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
last_seq = -1 last_seq = -1
@@ -303,6 +364,10 @@ class ConnectionManager:
get_status_func: Callable[[], Coroutine[Any, Any, Any]], get_status_func: Callable[[], Coroutine[Any, Any, Any]],
) -> None: ) -> None:
"""Background loop that polls for status changes and broadcasts.""" """Background loop that polls for status changes and broadcasts."""
# Foreground tracker is imported lazily so unit tests of the WS
# manager don't drag in platform-specific probe code.
from .foreground_service import get_foreground_info
while self._running: while self._running:
try: try:
# Only poll if we have connected clients # Only poll if we have connected clients
@@ -329,6 +394,28 @@ class ConnectionManager:
# Update cached status even without broadcast # Update cached status even without broadcast
self._last_status = status_dict self._last_status = status_dict
# Foreground process — poll at a coarser interval than media
# status. Broadcasts only fire on a real change, so a quiet
# desktop costs nothing.
now = time.time()
if (
now - self._last_foreground_poll
) >= self._foreground_poll_interval:
self._last_foreground_poll = now
try:
fg = await asyncio.to_thread(get_foreground_info)
fg_dict = fg.to_dict()
if self.foreground_changed(self._last_foreground, fg_dict):
self._last_foreground = fg_dict
await self.broadcast(
{"type": "foreground_update", "data": fg_dict}
)
logger.debug("Broadcast sent: foreground change")
else:
self._last_foreground = fg_dict
except Exception as e:
logger.debug("Foreground poll failed: %s", e)
await asyncio.sleep(self._poll_interval) await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError: except asyncio.CancelledError:
+118 -102
View File
@@ -15,8 +15,31 @@ logger = logging.getLogger(__name__)
# Thread pool for WinRT operations (they don't play well with asyncio) # Thread pool for WinRT operations (they don't play well with asyncio)
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt") _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
# Global storage for current album art (as bytes) # Cache an asyncio event loop per worker thread so the 500ms status poll
# doesn't allocate + tear down a new loop on every tick. Creating a loop
# every 0.5s churns CPU and leaks finalized loop references that linger in
# WinRT callbacks. With this helper a thread reuses one loop forever and
# we only pay the setup cost once per worker.
_thread_local = threading.local()
def _thread_loop() -> asyncio.AbstractEventLoop:
loop = getattr(_thread_local, "loop", None)
if loop is None or loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_thread_local.loop = loop
return loop
# Global storage for current album art (as bytes). Guarded by _art_lock so the
# WinRT polling thread and the FastAPI handler thread don't race on swap.
_current_album_art_bytes: bytes | None = None _current_album_art_bytes: bytes | None = None
_art_lock = threading.Lock()
# Identity of the track whose art is currently in _current_album_art_bytes.
# Used to gate the expensive WinRT thumbnail.open_read_async() so the bytes
# aren't re-decoded on every 500ms status poll.
_current_album_art_key: tuple | None = None
# Lock protecting _position_cache and _track_skip_pending from concurrent access # Lock protecting _position_cache and _track_skip_pending from concurrent access
_position_lock = threading.Lock() _position_lock = threading.Lock()
@@ -40,8 +63,9 @@ _track_skip_pending = {
def get_current_album_art() -> bytes | None: def get_current_album_art() -> bytes | None:
"""Get the current album art bytes.""" """Get the current album art bytes (thread-safe snapshot)."""
return _current_album_art_bytes with _art_lock:
return _current_album_art_bytes
# Windows-specific imports # Windows-specific imports
try: try:
@@ -161,8 +185,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
def _sync_get_media_status() -> dict[str, Any]: def _sync_get_media_status() -> dict[str, Any]:
"""Synchronously get media status (runs in thread pool).""" """Synchronously get media status (runs in thread pool)."""
import asyncio
result = { result = {
"state": "idle", "state": "idle",
"title": None, "title": None,
@@ -174,9 +196,7 @@ def _sync_get_media_status() -> dict[str, Any]:
} }
try: try:
# Create a new event loop for this thread loop = _thread_loop()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try: try:
# Get media session manager # Get media session manager
@@ -367,33 +387,54 @@ def _sync_get_media_status() -> dict[str, Any]:
except Exception as e: except Exception as e:
logger.debug(f"Timeline parse error: {e}") logger.debug(f"Timeline parse error: {e}")
# Try to get album art (requires media_props) # Try to get album art (requires media_props). Gated by track key so
# the WinRT IPC + bytes copy only runs when the track actually
# changes; otherwise we just preserve the existing cached bytes.
if media_props: if media_props:
try: track_key = (
thumbnail = media_props.thumbnail getattr(media_props, "title", "") or "",
if thumbnail: getattr(media_props, "artist", "") or "",
stream = loop.run_until_complete(thumbnail.open_read_async()) getattr(media_props, "album_title", "") or "",
if stream: )
size = stream.size global _current_album_art_bytes, _current_album_art_key
if size > 0 and size < 10 * 1024 * 1024: # Max 10MB if track_key == _current_album_art_key and _current_album_art_bytes:
from winsdk.windows.storage.streams import DataReader # Same track — reuse cached art bytes without touching WinRT.
reader = DataReader(stream) result["album_art_url"] = "/api/media/artwork"
loop.run_until_complete(reader.load_async(size)) else:
buffer = bytearray(size) try:
reader.read_bytes(buffer) thumbnail = media_props.thumbnail
reader.close() if thumbnail:
stream.close() stream = loop.run_until_complete(thumbnail.open_read_async())
if stream:
size = stream.size
if size > 0 and size < 10 * 1024 * 1024: # Max 10MB
from winsdk.windows.storage.streams import DataReader
reader = DataReader(stream)
loop.run_until_complete(reader.load_async(size))
buffer = bytearray(size)
reader.read_bytes(buffer)
reader.close()
stream.close()
global _current_album_art_bytes with _art_lock:
_current_album_art_bytes = bytes(buffer) _current_album_art_bytes = bytes(buffer)
result["album_art_url"] = "/api/media/artwork" _current_album_art_key = track_key
except Exception as e: result["album_art_url"] = "/api/media/artwork"
logger.debug(f"Failed to get album art: {e}") else:
# No thumbnail on this track — drop stale bytes so
# the ETag flips and clients don't keep showing the
# previous album's cover.
with _art_lock:
_current_album_art_bytes = None
_current_album_art_key = track_key
except Exception as e:
logger.debug(f"Failed to get album art: {e}")
result["source"] = session.source_app_user_model_id result["source"] = session.source_app_user_model_id
finally: finally:
loop.close() # Reuse the loop across calls — see _thread_loop above.
pass
except Exception as e: except Exception as e:
logger.error(f"Error getting media status: {e}") logger.error(f"Error getting media status: {e}")
@@ -439,35 +480,28 @@ def _find_best_session(manager, loop):
def _sync_media_command(command: str) -> bool: def _sync_media_command(command: str) -> bool:
"""Synchronously execute a media command (runs in thread pool).""" """Synchronously execute a media command (runs in thread pool)."""
import asyncio
try: try:
loop = asyncio.new_event_loop() loop = _thread_loop()
asyncio.set_event_loop(loop) manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
try:
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
session = _find_best_session(manager, loop)
if session is None:
return False
if command == "play":
return loop.run_until_complete(session.try_play_async())
elif command == "pause":
return loop.run_until_complete(session.try_pause_async())
elif command == "stop":
return loop.run_until_complete(session.try_stop_async())
elif command == "next":
return loop.run_until_complete(session.try_skip_next_async())
elif command == "previous":
return loop.run_until_complete(session.try_skip_previous_async())
return False return False
finally:
loop.close() session = _find_best_session(manager, loop)
if session is None:
return False
if command == "play":
return loop.run_until_complete(session.try_play_async())
elif command == "pause":
return loop.run_until_complete(session.try_pause_async())
elif command == "stop":
return loop.run_until_complete(session.try_stop_async())
elif command == "next":
return loop.run_until_complete(session.try_skip_next_async())
elif command == "previous":
return loop.run_until_complete(session.try_skip_previous_async())
return False
except Exception as e: except Exception as e:
logger.error(f"Error executing media command {command}: {e}") logger.error(f"Error executing media command {command}: {e}")
@@ -476,27 +510,20 @@ def _sync_media_command(command: str) -> bool:
def _sync_seek(position: float) -> bool: def _sync_seek(position: float) -> bool:
"""Synchronously seek to position.""" """Synchronously seek to position."""
import asyncio
try: try:
loop = asyncio.new_event_loop() loop = _thread_loop()
asyncio.set_event_loop(loop) manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
try: session = _find_best_session(manager, loop)
manager = loop.run_until_complete(MediaManager.request_async()) if session is None:
if manager is None: return False
return False
session = _find_best_session(manager, loop) position_ticks = int(position * 10_000_000)
if session is None: return loop.run_until_complete(
return False session.try_change_playback_position_async(position_ticks)
)
position_ticks = int(position * 10_000_000)
return loop.run_until_complete(
session.try_change_playback_position_async(position_ticks)
)
finally:
loop.close()
except Exception as e: except Exception as e:
logger.error(f"Error seeking: {e}") logger.error(f"Error seeking: {e}")
@@ -559,7 +586,7 @@ class WindowsMediaController(MediaController):
# Get media info in thread pool (avoids asyncio/WinRT issues) # Get media info in thread pool (avoids asyncio/WinRT issues)
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
media_info = await asyncio.wait_for( media_info = await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_get_media_status), loop.run_in_executor(_executor, _sync_get_media_status),
timeout=5.0 timeout=5.0
@@ -592,7 +619,7 @@ class WindowsMediaController(MediaController):
async def _run_command(self, command: str) -> bool: async def _run_command(self, command: str) -> bool:
"""Run a media command in the thread pool.""" """Run a media command in the thread pool."""
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await asyncio.wait_for( return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_media_command, command), loop.run_in_executor(_executor, _sync_media_command, command),
timeout=5.0 timeout=5.0
@@ -616,16 +643,15 @@ class WindowsMediaController(MediaController):
"""Stop playback.""" """Stop playback."""
return await self._run_command("stop") return await self._run_command("stop")
async def next_track(self) -> bool: async def _skip_track(self, command: str) -> bool:
"""Skip to next track.""" # Read the current title from the position cache instead of doing a
# Get current title before skipping # full WinRT round-trip (which can take up to 5s) just for one field.
try: with _position_lock:
status = await self.get_status() track_id = _position_cache.get("track_id") or ""
old_title = status.title or "" # track_id is "title:artist:duration" — extract just the title.
except Exception: old_title = track_id.split(":", 1)[0] if track_id else ""
old_title = ""
result = await self._run_command("next") result = await self._run_command(command)
if result: if result:
with _position_lock: with _position_lock:
_track_skip_pending["active"] = True _track_skip_pending["active"] = True
@@ -634,23 +660,13 @@ class WindowsMediaController(MediaController):
logger.debug(f"Track skip initiated, old title: {old_title}") logger.debug(f"Track skip initiated, old title: {old_title}")
return result return result
async def next_track(self) -> bool:
"""Skip to next track."""
return await self._skip_track("next")
async def previous_track(self) -> bool: async def previous_track(self) -> bool:
"""Go to previous track.""" """Go to previous track."""
# Get current title before skipping return await self._skip_track("previous")
try:
status = await self.get_status()
old_title = status.title or ""
except Exception:
old_title = ""
result = await self._run_command("previous")
if result:
with _position_lock:
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
logger.debug(f"Track skip initiated, old title: {old_title}")
return result
async def set_volume(self, volume: int) -> bool: async def set_volume(self, volume: int) -> bool:
"""Set system volume.""" """Set system volume."""
@@ -680,7 +696,7 @@ class WindowsMediaController(MediaController):
async def seek(self, position: float) -> bool: async def seek(self, position: float) -> bool:
"""Seek to position in seconds.""" """Seek to position in seconds."""
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await asyncio.wait_for( return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_seek, position), loop.run_in_executor(_executor, _sync_seek, position),
timeout=5.0 timeout=5.0
@@ -705,7 +721,7 @@ class WindowsMediaController(MediaController):
""" """
try: try:
import os import os
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: os.startfile(file_path)) await loop.run_in_executor(None, lambda: os.startfile(file_path))
logger.info(f"Opened file with default player: {file_path}") logger.info(f"Opened file with default player: {file_path}")
return True return True
+484 -58
View File
@@ -329,7 +329,7 @@ body.translations-loaded {
button:focus-visible { button:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.2); box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.2);
} }
input:focus-visible, input:focus-visible,
@@ -337,7 +337,7 @@ select:focus-visible,
textarea:focus-visible { textarea:focus-visible {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15); box-shadow: 0 0 0 3px rgba(var(--copper-rgb), 0.15);
} }
.tab-btn:focus-visible { .tab-btn:focus-visible {
@@ -1004,7 +1004,7 @@ button:disabled {
.controls button:focus-visible { .controls button:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 3px; outline-offset: 3px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25); box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.25);
} }
.mute-btn:focus-visible, .mute-btn:focus-visible,
@@ -1012,7 +1012,7 @@ button:disabled {
.vinyl-toggle-btn:focus-visible { .vinyl-toggle-btn:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25); box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.25);
} }
.controls button.primary { .controls button.primary {
@@ -1060,7 +1060,7 @@ button:disabled {
#volume-slider:hover::-webkit-slider-thumb { #volume-slider:hover::-webkit-slider-thumb {
transform: scale(1.3); transform: scale(1.3);
box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.4);
} }
#volume-slider::-moz-range-thumb { #volume-slider::-moz-range-thumb {
@@ -1075,7 +1075,7 @@ button:disabled {
#volume-slider:hover::-moz-range-thumb { #volume-slider:hover::-moz-range-thumb {
transform: scale(1.3); transform: scale(1.3);
box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.4);
} }
.volume-display { .volume-display {
@@ -1169,7 +1169,7 @@ button:disabled {
.vinyl-toggle-btn.active { .vinyl-toggle-btn.active {
color: var(--accent); color: var(--accent);
border-color: var(--accent); border-color: var(--accent);
background: rgba(29, 185, 84, 0.1); background: rgba(var(--copper-rgb), 0.1);
} }
.vinyl-toggle-btn svg { .vinyl-toggle-btn svg {
@@ -1393,7 +1393,7 @@ button:disabled {
border-radius: 4px; border-radius: 4px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: var(--sans, inherit);
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s ease, box-shadow 0.25s ease; transition: border-color 0.25s ease, box-shadow 0.25s ease;
@@ -1402,7 +1402,7 @@ button:disabled {
.audio-device-selector select:focus { .audio-device-selector select:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15); box-shadow: 0 0 0 3px rgba(var(--copper-rgb), 0.15);
} }
.audio-device-status { .audio-device-status {
@@ -1836,13 +1836,18 @@ button:disabled {
dialog { dialog {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
/* Editorial chrome to match the rest of the Studio Reference layout:
no rounded corners, hairline border, and a copper top accent that
lets the dialog read as a continuation of the magazine rather than
a generic Material modal. */
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-top: 1px solid var(--copper);
border-radius: 0;
padding: 0; padding: 0;
max-width: 500px; max-width: 520px;
width: 90%; width: 90%;
margin: auto; margin: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.65);
animation: dialogIn 0.25s ease-out; animation: dialogIn 0.25s ease-out;
overflow: visible; overflow: visible;
} }
@@ -2827,7 +2832,7 @@ button.primary svg {
.breadcrumb-item:hover { .breadcrumb-item:hover {
color: var(--accent); color: var(--accent);
background: rgba(29, 185, 84, 0.08); background: rgba(var(--copper-rgb), 0.08);
text-decoration: none; text-decoration: none;
} }
@@ -3072,19 +3077,25 @@ button.primary svg {
/* Browser List View */ /* Browser List View */
.browser-list { .browser-list {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 40px 1fr auto auto auto auto;
gap: 1px; column-gap: 1.25rem;
row-gap: 1px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-height: 200px; min-height: 200px;
} }
.browser-list > .browser-empty,
.browser-list > .browser-loading {
grid-column: 1 / -1;
}
/* List view column header */ /* List view column header */
.browser-list-header { .browser-list-header {
display: grid; display: grid;
grid-template-columns: 40px 1fr auto auto auto auto; grid-template-columns: subgrid;
grid-column: 1 / -1;
align-items: center; align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.688rem; font-size: 0.688rem;
font-weight: 600; font-weight: 600;
@@ -3102,9 +3113,9 @@ button.primary svg {
.browser-list-item { .browser-list-item {
display: grid; display: grid;
grid-template-columns: 40px 1fr auto auto auto auto; grid-template-columns: subgrid;
grid-column: 1 / -1;
align-items: center; align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
@@ -3235,12 +3246,14 @@ button.primary svg {
} }
.browser-item { .browser-item {
background: var(--bg-tertiary); /* Match the editorial card language used elsewhere on the page
transparent background, hairline border, copper-on-hover. */
background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 10px; border-radius: 0;
padding: 0.6rem; padding: 0.6rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -3250,6 +3263,11 @@ button.primary svg {
animation-delay: calc(var(--item-index, 0) * 25ms); animation-delay: calc(var(--item-index, 0) * 25ms);
} }
.browser-item:hover {
border-color: rgba(var(--copper-rgb), 0.45);
background: rgba(var(--copper-rgb), 0.04);
}
@keyframes itemFadeIn { @keyframes itemFadeIn {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@@ -3286,14 +3304,21 @@ button.primary svg {
height: 56px; height: 56px;
font-size: 1.75rem; font-size: 1.75rem;
border-radius: 14px; border-radius: 14px;
background: rgba(29, 185, 84, 0.1); background: rgba(var(--copper-rgb), 0.1);
border: 1px solid rgba(29, 185, 84, 0.15); border: 1px solid rgba(var(--copper-rgb), 0.15);
transition: all 0.25s; transition: all 0.25s;
} }
/* Root folder SVG ships with hardcoded width=24 height=24 (browser.js folderSvg),
which renders at ~43% of the 56px icon box. Override to fill it properly. */
.browser-item.browser-root-folder .browser-icon > svg {
width: 60%;
height: 60%;
}
.browser-item.browser-root-folder:hover .browser-icon { .browser-item.browser-root-folder:hover .browser-icon {
background: rgba(29, 185, 84, 0.18); background: rgba(var(--copper-rgb), 0.18);
border-color: rgba(29, 185, 84, 0.3); border-color: rgba(var(--copper-rgb), 0.3);
transform: scale(1.05); transform: scale(1.05);
} }
@@ -3638,9 +3663,12 @@ button.primary svg {
display: none; display: none;
} }
.browser-list-header { .browser-list {
grid-template-columns: 32px 1fr auto auto; grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem; column-gap: 0.875rem;
}
.browser-list-header {
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
} }
@@ -3649,8 +3677,6 @@ button.primary svg {
} }
.browser-list-item { .browser-list-item {
grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem;
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
} }
@@ -3720,17 +3746,13 @@ button.primary svg {
display: none; display: none;
} }
.browser-list-header { .browser-list {
grid-template-columns: 40px 1fr auto auto auto; grid-template-columns: 40px 1fr auto auto auto;
} }
.browser-list-header span:nth-child(3) { .browser-list-header span:nth-child(3) {
display: none; display: none;
} }
.browser-list-item {
grid-template-columns: 40px 1fr auto auto auto;
}
} }
/* Update Banner */ /* Update Banner */
@@ -3963,7 +3985,13 @@ html {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.container { padding: 48px 18px 24px; } .container { padding: 32px 18px 20px; }
}
/* Phones: trim the editorial spread further so the first viewport isn't
90% chrome. The 56px top pad eats a third of a 360x640 screen. */
@media (max-width: 480px) {
.container { padding: 16px 12px 16px; }
} }
/* ─── Folio marks (page corners, all tabs) ────────────────── */ /* ─── Folio marks (page corners, all tabs) ────────────────── */
@@ -4416,15 +4444,9 @@ header .brand-sub {
} }
/* ─── Vinyl stage ──────────────────────────────────────────── */ /* ─── Vinyl stage ──────────────────────────────────────────── */
/* Aspect-ratio is intentionally wider than tall: the sleeve+disc
composition only fills the top ~82% of a square; a strict 1:1 stage
left an ~18% empty band below the disc and forced the grid row
taller than the masthead column, painting a large dead gap at the
bottom of the page. 1:0.85 trims that band while keeping the disc
(bottom anchor at top:19.4% + 63% = 82.4% of height) safely inside. */
.album-art-container.vinyl-stage { .album-art-container.vinyl-stage {
position: relative; position: relative;
aspect-ratio: 1 / 0.85; aspect-ratio: 1;
width: 100%; width: 100%;
max-width: none; max-width: none;
padding: 0; padding: 0;
@@ -4561,7 +4583,7 @@ header .brand-sub {
top: 26%; top: 26%;
right: -6%; right: -6%;
width: 36%; width: 36%;
aspect-ratio: 1; height: 36%;
pointer-events: none; pointer-events: none;
transform-origin: 88% 12%; transform-origin: 88% 12%;
transform: rotate(-22deg); transform: rotate(-22deg);
@@ -4667,6 +4689,12 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
-2px 8px 24px rgba(0, 0, 0, 0.5), -2px 8px 24px rgba(0, 0, 0, 0.5),
-4px 18px 44px rgba(0, 0, 0, 0.35); -4px 18px 44px rgba(0, 0, 0, 0.35);
overflow: hidden; overflow: hidden;
/* Subtle counterclockwise tilt sleeve rests on the disc as if
casually placed, breaking up the otherwise rigid rectilinear
grid. The shadow above carries the same diagonal so the lean
reads as physical rather than transformed. */
transform: rotate(-2deg);
transform-origin: 50% 60%;
} }
:root[data-theme="light"] .vinyl-stage .sleeve { :root[data-theme="light"] .vinyl-stage .sleeve {
background: transparent; background: transparent;
@@ -7387,9 +7415,15 @@ select option {
font-size: 9px; font-size: 9px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
} }
/* Let the compact icon fill its thumb-wrapper (matches grid view behaviour).
The previous 28x28 size left the icon stranded at the wrapper's top-left
because .browser-thumb-wrapper is not a flex container. The wrapper square
is sized by the grid; the emoji inside .browser-icon centers via the base
flex rules. */
.browser-container .browser-grid-compact .browser-icon { .browser-container .browser-grid-compact .browser-icon {
width: 28px; width: 100%;
height: 28px; height: 100%;
font-size: 2.25rem;
} }
.browser-container .browser-item { .browser-container .browser-item {
background: transparent !important; background: transparent !important;
@@ -7950,10 +7984,16 @@ select option {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
/* Allow text to wrap so we don't ellipsis-truncate the model name */ min-width: 0;
}
/* Truncate the monitor name itself, not its sibling badge putting
overflow:hidden on the parent flex container clipped the favourite
star whenever the model name was long enough to ellipsis. */
.display-container .display-monitor-name-text {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0;
} }
.display-container .display-monitor-details { .display-container .display-monitor-details {
font-family: var(--mono); font-family: var(--mono);
@@ -7973,6 +8013,7 @@ select option {
margin: 0; margin: 0;
line-height: 0; line-height: 0;
vertical-align: middle; vertical-align: middle;
flex-shrink: 0;
filter: drop-shadow(0 0 4px var(--copper-glow)); filter: drop-shadow(0 0 4px var(--copper-glow));
} }
.display-container .display-primary-badge svg { .display-container .display-primary-badge svg {
@@ -8015,24 +8056,36 @@ select option {
background: rgba(var(--copper-rgb), 0.06) !important; background: rgba(var(--copper-rgb), 0.06) !important;
} }
/* Brightness control row */ /* Slider rows (brightness + contrast share this layout) */
.display-container .display-brightness-control { .display-container .display-slider-row {
display: flex; display: grid;
grid-template-columns: 18px minmax(0, auto) 1fr auto;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
}
.display-container .display-slider-row.display-brightness-control {
padding-top: 16px; padding-top: 16px;
border-top: 1px solid var(--rule); border-top: 1px solid var(--rule);
} }
.display-container .display-brightness-icon { .display-container .display-slider-icon {
color: var(--ink-mute); color: var(--ink-mute);
width: 18px !important; width: 18px !important;
height: 18px !important; height: 18px !important;
flex-shrink: 0; flex-shrink: 0;
} }
.display-container .display-brightness-slider { .display-container .display-slider-label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
white-space: nowrap;
}
.display-container .display-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
flex: 1; flex: 1;
width: 100%;
height: 2px !important; height: 2px !important;
background: var(--rule-strong); background: var(--rule-strong);
border-radius: 0; border-radius: 0;
@@ -8042,7 +8095,7 @@ select option {
border: 0 !important; border: 0 !important;
min-width: 0; min-width: 0;
} }
.display-container .display-brightness-slider::-webkit-slider-thumb { .display-container .display-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 14px; width: 14px;
@@ -8052,20 +8105,24 @@ select option {
box-shadow: 0 0 12px var(--copper-glow); box-shadow: 0 0 12px var(--copper-glow);
border: 0; border: 0;
cursor: grab; cursor: grab;
transition: transform 140ms var(--ease);
} }
.display-container .display-brightness-slider::-moz-range-thumb { .display-container .display-slider::-moz-range-thumb {
width: 14px; width: 14px;
height: 14px; height: 14px;
background: var(--copper); background: var(--copper);
border-radius: 50%; border-radius: 50%;
border: 0; border: 0;
cursor: grab; cursor: grab;
transition: transform 140ms var(--ease);
} }
.display-container .display-brightness-slider:disabled { .display-container .display-slider:hover::-webkit-slider-thumb { transform: scale(1.15); }
.display-container .display-slider:hover::-moz-range-thumb { transform: scale(1.15); }
.display-container .display-slider:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.display-container .display-brightness-value { .display-container .display-slider-value {
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 12px;
color: var(--copper); color: var(--copper);
@@ -8075,6 +8132,57 @@ select option {
text-align: right; text-align: right;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
/* Picture tuning section input source, color preset, picture mode.
The underlying <select> is hidden by IconSelect; the visible trigger
inherits the editorial .icon-select-trigger overrides defined later
in this file. */
.display-container .display-tuning {
padding-top: 18px;
border-top: 1px solid var(--rule);
display: flex;
flex-direction: column;
gap: 14px;
}
.display-container .display-tuning-title {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--ink-faint);
display: flex;
align-items: center;
gap: 10px;
}
.display-container .display-tuning-title::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, var(--rule), transparent);
}
.display-container .display-tuning-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.display-container .display-tuning-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.display-container .display-tuning-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
white-space: nowrap;
}
/* Make the IconSelect trigger fill the field width (cards are narrow) */
.display-container .display-tuning-field .icon-select-trigger {
width: 100%;
justify-content: flex-start;
}
.display-container .display-monitors > .empty-state-illustration { .display-container .display-monitors > .empty-state-illustration {
grid-column: 1 / -1; grid-column: 1 / -1;
text-align: center; text-align: center;
@@ -9213,3 +9321,321 @@ body.is-fullscreen-player .now-playing .vu-meter {
body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; } body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; }
:root[data-theme="light"] body.is-fullscreen-player .fs-bloom { opacity: 0.22; } :root[data-theme="light"] body.is-fullscreen-player .fs-bloom { opacity: 0.22; }
} }
/*
FOREGROUND container editorial process plate
*/
.foreground-container {
background: transparent;
border: 0;
padding: 0;
box-shadow: none;
margin-top: 28px;
}
.foreground-stage {
min-height: 360px;
}
/* Match the inter-section gap used between .settings-section blocks
in the Settings tab keeps cadence consistent across tabs. */
.display-container > * + * {
margin-top: 16px;
}
.foreground-card {
position: relative;
display: block;
padding: clamp(24px, 3vw, 40px) clamp(24px, 3vw, 40px) 28px;
border: 1px solid var(--rule);
border-top: 2px solid var(--copper);
background:
radial-gradient(120% 80% at 0% 0%, rgba(var(--copper-rgb), 0.05), transparent 60%),
var(--bg-paper);
box-shadow:
0 1px 0 var(--bg-paper),
0 28px 60px -28px rgba(0, 0, 0, 0.45),
0 8px 20px -10px rgba(0, 0, 0, 0.25);
}
.foreground-card[data-fullscreen="1"] {
border-top-color: var(--copper-hi);
box-shadow:
0 1px 0 var(--bg-paper),
0 28px 60px -28px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(var(--copper-rgb), 0.18),
0 0 60px -12px var(--copper-glow);
}
.foreground-card .fg-kicker {
display: flex;
align-items: center;
gap: 14px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--copper);
margin-bottom: 22px;
}
.foreground-card .fg-kicker::before,
.foreground-card .fg-kicker::after {
content: "";
height: 1px;
background: var(--copper);
opacity: 0.6;
flex: 0 0 24px;
}
.foreground-card .fg-kicker::after { flex: 1 0 auto; }
.foreground-card .fg-process {
font-family: var(--serif);
font-weight: 400;
font-size: clamp(34px, 4.4vw, 56px);
line-height: 1.02;
letter-spacing: -0.02em;
font-variation-settings: 'opsz' 144;
color: var(--ink);
margin: 0 0 10px;
word-break: break-word;
overflow-wrap: anywhere;
transition: color 180ms var(--ease, ease);
}
.foreground-card .fg-process:hover {
color: var(--copper-hi);
}
.foreground-card .fg-window-title {
font-family: var(--serif);
font-style: italic;
font-size: 20px;
font-weight: 300;
color: var(--ink-soft);
font-variation-settings: 'opsz' 60;
margin-bottom: 22px;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
}
.foreground-card .fg-window-title:empty { display: none; }
.foreground-card .fg-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 28px;
}
.foreground-card .fg-chips:empty { display: none; }
.fg-chip {
display: inline-flex;
align-items: center;
padding: 5px 11px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-soft);
background: transparent;
border: 1px solid var(--rule-strong);
border-radius: 999px;
line-height: 1.2;
white-space: nowrap;
}
.fg-chip.fg-chip-accent {
color: var(--copper);
border-color: var(--copper);
background: rgba(var(--copper-rgb), 0.07);
}
.fg-chip.fg-chip-mute {
color: var(--ink-mute);
border-color: var(--rule);
}
.foreground-card .fg-details {
display: block;
margin: 0;
border-top: 1px solid var(--rule);
}
.foreground-card .fg-row {
display: grid;
grid-template-columns: minmax(160px, 220px) 1fr;
gap: 24px;
padding: 14px 0;
border-bottom: 1px solid var(--rule);
align-items: baseline;
min-width: 0;
}
.foreground-card .fg-row dt {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--copper);
margin: 0;
}
.foreground-card .fg-row dd {
font-family: var(--serif);
font-style: italic;
font-size: 18px;
color: var(--ink);
font-variation-settings: 'opsz' 30;
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.foreground-card .fg-mono {
font-family: var(--mono);
font-style: normal;
font-size: 13px;
letter-spacing: 0.02em;
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
word-break: break-all;
}
.foreground-empty {
padding: 60px 24px;
text-align: center;
color: var(--ink-mute);
}
.foreground-empty svg {
width: 64px;
height: 64px;
margin-bottom: 14px;
opacity: 0.55;
color: var(--ink-faint);
}
.foreground-empty p {
font-family: var(--serif);
font-style: italic;
font-size: 18px;
color: var(--ink-soft);
margin: 0;
}
.foreground-empty .foreground-empty-error {
margin-top: 10px;
font-family: var(--mono);
font-style: normal;
font-size: 11px;
letter-spacing: 0.06em;
color: var(--ink-mute);
word-break: break-word;
}
/* ─── Header status badge ──────────────────────────────────── */
.foreground-status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 12px 0 10px;
margin-right: 4px;
background: transparent;
border: 1px solid var(--rule-strong);
border-radius: 999px;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.04em;
cursor: pointer;
max-width: 240px;
transition: color 180ms ease, border-color 180ms ease, background 180ms ease;
}
.foreground-status-badge:hover {
color: var(--ink);
border-color: var(--copper);
background: rgba(var(--copper-rgb), 0.06);
}
.foreground-status-badge.hidden { display: none !important; }
.foreground-status-badge .fg-badge-mark {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink-mute);
flex-shrink: 0;
}
.foreground-status-badge.is-media .fg-badge-mark,
.foreground-status-badge.is-fullscreen .fg-badge-mark {
background: var(--copper);
box-shadow: 0 0 8px var(--copper-glow);
}
.foreground-status-badge.is-fullscreen {
border-color: var(--copper);
color: var(--ink);
}
.foreground-status-badge .fg-badge-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.foreground-status-badge .fg-badge-tag {
color: var(--copper);
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 10px;
flex-shrink: 0;
}
.foreground-status-badge .fg-badge-tag.hidden { display: none; }
/* ─── Light theme overrides ──────────────────────────────── */
:root[data-theme="light"] .foreground-card {
background:
radial-gradient(120% 80% at 0% 0%, rgba(var(--copper-rgb), 0.05), transparent 60%),
var(--bg-paper);
box-shadow:
0 1px 0 var(--bg-paper),
0 22px 50px -24px rgba(26, 23, 21, 0.20),
0 6px 16px -8px rgba(26, 23, 21, 0.12);
}
:root[data-theme="light"] .foreground-card[data-fullscreen="1"] {
box-shadow:
0 1px 0 var(--bg-paper),
0 22px 50px -24px rgba(26, 23, 21, 0.28),
0 0 0 1px rgba(var(--copper-rgb), 0.20),
0 0 50px -12px var(--copper-glow);
}
:root[data-theme="light"] .foreground-status-badge {
border-color: rgba(26, 23, 21, 0.18);
}
:root[data-theme="light"] .foreground-status-badge:hover {
background: rgba(var(--copper-rgb), 0.08);
}
/* ─── Mobile breakpoint ──────────────────────────────────── */
@media (max-width: 720px) {
.foreground-card {
padding: 22px 18px 20px;
}
.foreground-card .fg-process {
font-size: 30px;
}
.foreground-card .fg-window-title {
font-size: 16px;
}
.foreground-card .fg-row {
grid-template-columns: 1fr;
gap: 4px;
padding: 12px 0;
}
.foreground-card .fg-row dd {
font-size: 16px;
}
.foreground-status-badge {
max-width: 160px;
}
.foreground-status-badge .fg-badge-name {
max-width: 80px;
}
.foreground-status-badge .fg-badge-tag {
display: none;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 37 KiB

+29 -6
View File
@@ -1,10 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
<defs> <defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" /> <stop offset="0%" stop-color="#0B3D3B"/>
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" /> <stop offset="100%" stop-color="#1A6B5E"/>
</linearGradient> </linearGradient>
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3"/>
<feOffset dx="3" dy="5"/>
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
</defs> </defs>
<circle cx="50" cy="50" r="45" fill="url(#grad)"/> <g clip-path="url(#clip)">
<path fill="white" d="M35 25 L35 75 L75 50 Z"/> <rect width="256" height="256" fill="url(#bg)"/>
<rect width="256" height="256" fill="url(#sheen)"/>
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
<polyline points="66,72 105,128 66,184"/>
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
</g>
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
fill="#F5F1E8"
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
filter="url(#triShadow)"/>
</g>
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 421 B

After

Width:  |  Height:  |  Size: 1.6 KiB

+78 -72
View File
@@ -26,16 +26,16 @@
</div> </div>
</div> </div>
<div class="mini-controls"> <div class="mini-controls">
<button class="mini-control-btn mini-nav-btn" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous"> <button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" data-i18n-aria-label="player.previous" title="Previous" aria-label="Previous">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button> </button>
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause"> <button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause" aria-label="Play/Pause">
<svg viewBox="0 0 24 24" id="mini-play-pause-icon"> <svg viewBox="0 0 24 24" id="mini-play-pause-icon" aria-hidden="true" focusable="false">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
</button> </button>
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next"> <button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" data-i18n-aria-label="player.next" title="Next" aria-label="Next">
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button> </button>
</div> </div>
<div class="mini-progress-container"> <div class="mini-progress-container">
@@ -48,8 +48,8 @@
</div> </div>
</div> </div>
<div class="mini-volume-container"> <div class="mini-volume-container">
<button class="mini-control-btn" onclick="toggleMute()" id="mini-btn-mute" title="Mute"> <button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute" aria-label="Mute" aria-pressed="false">
<svg viewBox="0 0 24 24" id="mini-mute-icon"> <svg viewBox="0 0 24 24" id="mini-mute-icon" aria-hidden="true" focusable="false">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg> </svg>
</button> </button>
@@ -67,7 +67,7 @@
<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>
<input type="text" id="token-input" data-i18n-placeholder="auth.placeholder" placeholder="Enter API Token" autocomplete="off"> <input type="text" id="token-input" data-i18n-placeholder="auth.placeholder" placeholder="Enter API Token" autocomplete="off">
<button class="btn-connect" onclick="authenticate()" data-i18n="auth.connect">Connect</button> <button class="btn-connect" data-onclick="authenticate()" data-i18n="auth.connect">Connect</button>
<div class="help-text"> <div class="help-text">
<p data-i18n="auth.help">To get your token, run:</p> <p data-i18n="auth.help">To get your token, run:</p>
<code>media-server --show-token</code> <code>media-server --show-token</code>
@@ -88,26 +88,26 @@
</div> </div>
<div class="header-toolbar"> <div class="header-toolbar">
<div id="headerLinks" class="header-links"></div> <div id="headerLinks" class="header-links"></div>
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation"> <a class="header-btn" href="/docs" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" title="API Documentation" aria-label="API Documentation">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg> <svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
</a> </a>
<button class="header-btn" onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About"> <button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> <svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
</button> </button>
<div class="accent-picker"> <div class="accent-picker">
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color"> <button class="header-btn" data-onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
<span class="accent-dot" id="accentDot"></span> <span class="accent-dot" id="accentDot"></span>
</button> </button>
<div class="accent-picker-dropdown" id="accentDropdown"></div> <div class="accent-picker-dropdown" id="accentDropdown"></div>
</div> </div>
<button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle"> <button class="header-btn" data-onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg> <svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
</button> </button>
<button class="header-btn" onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle"> <button class="header-btn" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
<svg id="fullscreen-icon-enter" viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zm0 14v-2h3v-3h2v5h-5zM5 14h2v3h3v2H5v-5z"/></svg> <svg id="fullscreen-icon-enter" viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zm0 14v-2h3v-3h2v5h-5zM5 14h2v3h3v2H5v-5z"/></svg>
<svg id="fullscreen-icon-exit" viewBox="0 0 24 24" style="display:none"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg> <svg id="fullscreen-icon-exit" viewBox="0 0 24 24" style="display:none"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
</button> </button>
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle"> <button class="header-btn" data-onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;"> <svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/> <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg> </svg>
@@ -115,12 +115,12 @@
<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>
<select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language"> <select id="locale-select" class="header-locale" data-onchange="changeLocale()" title="Change language">
<option value="en">EN</option> <option value="en">EN</option>
<option value="ru">RU</option> <option value="ru">RU</option>
</select> </select>
<span class="header-toolbar-sep"></span> <span class="header-toolbar-sep"></span>
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout"> <button class="header-btn header-btn-logout" data-onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg> <svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button> </button>
</div> </div>
@@ -136,29 +136,29 @@
<!-- Connection Banner --> <!-- Connection Banner -->
<div class="connection-banner hidden" id="connectionBanner"> <div class="connection-banner hidden" id="connectionBanner">
<span id="connectionBannerText"></span> <span id="connectionBannerText"></span>
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button> <button class="connection-banner-btn" id="connectionBannerBtn" data-onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
</div> </div>
<!-- Tab Bar (editorial: numbered, italic active) --> <!-- Tab Bar (editorial: numbered, italic active) -->
<div class="tab-bar" id="tabBar" role="tablist"> <div class="tab-bar" id="tabBar" role="tablist">
<div class="tab-indicator" id="tabIndicator"></div> <div class="tab-indicator" id="tabIndicator"></div>
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0"> <button class="tab-btn active" data-tab="player" data-onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
<span class="tab-num">01</span> <span class="tab-num">01</span>
<span data-i18n="tab.player">Now Spinning</span> <span data-i18n="tab.player">Now Spinning</span>
</button> </button>
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1"> <button class="tab-btn" data-tab="display" data-onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
<span class="tab-num">02</span> <span class="tab-num">02</span>
<span data-i18n="tab.display">Display</span> <span data-i18n="tab.display">Display</span>
</button> </button>
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1"> <button class="tab-btn" data-tab="browser" data-onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
<span class="tab-num">03</span> <span class="tab-num">03</span>
<span data-i18n="tab.browser">Library</span> <span data-i18n="tab.browser">Library</span>
</button> </button>
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1"> <button class="tab-btn" data-tab="quick-actions" data-onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
<span class="tab-num">04</span> <span class="tab-num">04</span>
<span data-i18n="tab.quick_access">Quick Access</span> <span data-i18n="tab.quick_access">Quick Access</span>
</button> </button>
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1"> <button class="tab-btn" data-tab="settings" data-onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
<span class="tab-num">05</span> <span class="tab-num">05</span>
<span data-i18n="tab.settings">Settings</span> <span data-i18n="tab.settings">Settings</span>
</button> </button>
@@ -172,7 +172,7 @@
<span class="fs-chrome-sep">·</span> <span class="fs-chrome-sep">·</span>
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span> <span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
</div> </div>
<button class="fs-chrome-exit" onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen"> <button class="fs-chrome-exit" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
<span data-i18n="player.fullscreen.exit_short">Exit</span> <span data-i18n="player.fullscreen.exit_short">Exit</span>
<kbd class="fs-chrome-kbd">ESC</kbd> <kbd class="fs-chrome-kbd">ESC</kbd>
@@ -206,19 +206,19 @@
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true"> <svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
<defs> <defs>
<linearGradient id="armGrad" x1="0" x2="1"> <linearGradient id="armGrad" x1="0" x2="1">
<stop offset="0" stop-color="#3a3528"/> <stop offset="0" stop-color="#6d5f44"/>
<stop offset="0.5" stop-color="#9C937F"/> <stop offset="0.5" stop-color="#d8c39a"/>
<stop offset="1" stop-color="#5C5447"/> <stop offset="1" stop-color="#8a7a5a"/>
</linearGradient> </linearGradient>
</defs> </defs>
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/> <circle cx="176" cy="24" r="14" fill="#2a241c" stroke="#9C835A" stroke-width="1.5"/>
<circle cx="176" cy="24" r="6" fill="#3A3528"/> <circle cx="176" cy="24" r="6" fill="#5C5447"/>
<circle cx="176" cy="24" r="2" fill="#E08038"/> <circle cx="176" cy="24" r="2.5" fill="#E08038"/>
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="3.5" stroke-linecap="round"/> <line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/> <rect x="180" y="14" width="14" height="20" fill="#3A3528" stroke="#9C835A" stroke-width="1"/>
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/> <rect x="56" y="128" width="22" height="18" rx="2" fill="#3A3528" stroke="#9C835A" stroke-width="1" transform="rotate(-45 67 137)"/>
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/> <circle cx="62" cy="138" r="3.5" fill="#E08038" opacity="0.95"/>
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/> <circle cx="62" cy="138" r="7" fill="none" stroke="#E08038" stroke-width="0.8" opacity="0.5"/>
</svg> </svg>
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas> <canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
</div> </div>
@@ -267,13 +267,13 @@
</div> </div>
<div class="controls"> <div class="controls">
<button class="btn-trans" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous"> <button class="btn-trans" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg> <svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button> </button>
<button class="btn-trans primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause"> <button class="btn-trans primary" data-onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
<svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg> <svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg>
</button> </button>
<button class="btn-trans" onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next"> <button class="btn-trans" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg> <svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button> </button>
@@ -287,7 +287,7 @@
</div> </div>
<!-- Volume control: mute + slim slider, integrated --> <!-- Volume control: mute + slim slider, integrated -->
<div class="vu-volume"> <div class="vu-volume">
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute"> <button class="mute-btn" data-onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
<svg viewBox="0 0 24 24" id="mute-icon"> <svg viewBox="0 0 24 24" id="mute-icon">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg> </svg>
@@ -301,7 +301,7 @@
<!-- Hidden but functional: legacy display + visualizer toggle. --> <!-- Hidden but functional: legacy display + visualizer toggle. -->
<div class="visually-hidden"> <div class="visually-hidden">
<div id="volume-display">50%</div> <div id="volume-display">50%</div>
<button onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none"> <button data-onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg> <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
</button> </button>
</div> </div>
@@ -318,34 +318,34 @@
<div class="browser-toolbar" id="browserToolbar"> <div class="browser-toolbar" id="browserToolbar">
<div class="browser-toolbar-left"> <div class="browser-toolbar-left">
<div class="view-toggle"> <div class="view-toggle">
<button class="view-toggle-btn active" id="viewGridBtn" onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view"> <button class="view-toggle-btn active" id="viewGridBtn" data-onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
</button> </button>
<button class="view-toggle-btn" id="viewCompactBtn" onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view"> <button class="view-toggle-btn" id="viewCompactBtn" data-onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg>
</button> </button>
<button class="view-toggle-btn" id="viewListBtn" onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view"> <button class="view-toggle-btn" id="viewListBtn" data-onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
</button> </button>
</div> </div>
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh"> <button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" data-onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</button> </button>
<button class="browser-play-all-btn" id="playAllBtn" onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;"> <button class="browser-play-all-btn" id="playAllBtn" data-onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg> <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
</button> </button>
</div> </div>
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;"> <div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
<svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg> <svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." oninput="onBrowserSearch()"> <input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." data-oninput="onBrowserSearch()">
<button class="browser-search-clear" id="browserSearchClear" onclick="clearBrowserSearch()" style="display: none;"> <button class="browser-search-clear" id="browserSearchClear" data-onclick="clearBrowserSearch()" style="display: none;">
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg> <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button> </button>
</div> </div>
<div class="browser-toolbar-right"> <div class="browser-toolbar-right">
<label class="items-per-page-label"> <label class="items-per-page-label">
<span data-i18n="browser.items_per_page">Items per page:</span> <span data-i18n="browser.items_per_page">Items per page:</span>
<select id="itemsPerPageSelect" onchange="onItemsPerPageChanged()"> <select id="itemsPerPageSelect" data-onchange="onItemsPerPageChanged()">
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100" selected>100</option> <option value="100" selected>100</option>
@@ -366,13 +366,13 @@
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" id="browserPagination" style="display: none;"> <div class="pagination" id="browserPagination" style="display: none;">
<button id="prevPage" onclick="previousPage()" data-i18n="browser.previous">Previous</button> <button id="prevPage" data-onclick="previousPage()" data-i18n="browser.previous">Previous</button>
<div class="pagination-center"> <div class="pagination-center">
<span data-i18n="browser.page">Page</span> <span data-i18n="browser.page">Page</span>
<input type="number" id="pageInput" class="page-input" min="1" value="1" onchange="goToPage()"> <input type="number" id="pageInput" class="page-input" min="1" value="1" data-onchange="goToPage()">
<span id="pageTotal">/ 1</span> <span id="pageTotal">/ 1</span>
</div> </div>
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button> <button id="nextPage" data-onclick="nextPage()" data-i18n="browser.next">Next</button>
<span class="pagination-showing" id="paginationShowing"></span> <span class="pagination-showing" id="paginationShowing"></span>
</div> </div>
</div> </div>
@@ -398,7 +398,7 @@
<div class="audio-device-selector"> <div class="audio-device-selector">
<label> <label>
<span data-i18n="settings.audio.device">Loopback Device</span> <span data-i18n="settings.audio.device">Loopback Device</span>
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()"> <select id="audioDeviceSelect" data-onchange="onAudioDeviceChanged()">
<option value="" data-i18n="settings.audio.auto">Auto-detect</option> <option value="" data-i18n="settings.audio.auto">Auto-detect</option>
</select> </select>
</label> </label>
@@ -434,7 +434,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="add-card" onclick="showAddFolderDialog()"> <div class="add-card" data-onclick="showAddFolderDialog()">
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
@@ -464,7 +464,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="add-card" onclick="showAddScriptDialog()"> <div class="add-card" data-onclick="showAddScriptDialog()">
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
@@ -496,7 +496,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="add-card" onclick="showAddLinkDialog()"> <div class="add-card" data-onclick="showAddLinkDialog()">
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
@@ -522,20 +522,20 @@
<td colspan="4" class="empty-state"> <td colspan="4" class="empty-state">
<div class="empty-state-illustration"> <div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg> <svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
<p>No callbacks configured. Click "Add" to create one.</p> <p data-i18n="callbacks.empty">No callbacks configured. Click "Add" to create one.</p>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="add-card" onclick="showAddCallbackDialog()"> <div class="add-card" data-onclick="showAddCallbackDialog()">
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
</details> </details>
</div> </div>
<!-- Display Control Section --> <!-- Display Control Section (monitors first, foreground overview below) -->
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display"> <div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
<div class="display-monitors" id="displayMonitors"> <div class="display-monitors" id="displayMonitors">
<div class="empty-state-illustration"> <div class="empty-state-illustration">
@@ -543,6 +543,12 @@
<p data-i18n="display.loading">Loading monitors...</p> <p data-i18n="display.loading">Loading monitors...</p>
</div> </div>
</div> </div>
<div class="foreground-stage" id="foregroundStage">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
<p data-i18n="foreground.loading">Waiting for foreground signal…</p>
</div>
</div>
</div> </div>
</div> </div>
@@ -551,7 +557,7 @@
<div class="dialog-header"> <div class="dialog-header">
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">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" data-onsubmit="saveScript(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="scriptOriginalName"> <input type="hidden" id="scriptOriginalName">
<input type="hidden" id="scriptIsEdit"> <input type="hidden" id="scriptIsEdit">
@@ -593,13 +599,13 @@
<div class="params-section"> <div class="params-section">
<div class="params-header"> <div class="params-header">
<span data-i18n="scripts.field.parameters">Parameters</span> <span data-i18n="scripts.field.parameters">Parameters</span>
<button type="button" class="btn-small" onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button> <button type="button" class="btn-small" data-onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
</div> </div>
<div id="scriptParamsContainer"></div> <div id="scriptParamsContainer"></div>
</div> </div>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button> <button type="button" class="btn-secondary" data-onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button> <button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
</div> </div>
</form> </form>
@@ -608,14 +614,14 @@
<!-- Script Parameters Input Dialog (shown before executing scripts with params) --> <!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
<dialog id="scriptParamsDialog"> <dialog id="scriptParamsDialog">
<div class="dialog-header"> <div class="dialog-header">
<h3 id="scriptParamsDialogTitle">Execute Script</h3> <h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
</div> </div>
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)"> <form id="scriptParamsForm" data-onsubmit="submitScriptWithParams(event)">
<div class="dialog-body"> <div class="dialog-body">
<div id="scriptParamsInputs"></div> <div id="scriptParamsInputs"></div>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button> <button type="button" class="btn-secondary" data-onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button> <button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
</div> </div>
</form> </form>
@@ -626,7 +632,7 @@
<div class="dialog-header"> <div class="dialog-header">
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">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" data-onsubmit="saveCallback(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="callbackIsEdit"> <input type="hidden" id="callbackIsEdit">
@@ -664,7 +670,7 @@
</label> </label>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button> <button type="button" class="btn-secondary" data-onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button> <button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
</div> </div>
</form> </form>
@@ -675,7 +681,7 @@
<div class="dialog-header"> <div class="dialog-header">
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3> <h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
</div> </div>
<form id="linkForm" onsubmit="saveLink(event)"> <form id="linkForm" data-onsubmit="saveLink(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="linkOriginalName"> <input type="hidden" id="linkOriginalName">
<input type="hidden" id="linkIsEdit"> <input type="hidden" id="linkIsEdit">
@@ -710,7 +716,7 @@
</label> </label>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button> <button type="button" class="btn-secondary" data-onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button> <button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
</div> </div>
</form> </form>
@@ -737,7 +743,7 @@
</div> </div>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button> <button type="button" class="btn-secondary" data-onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
</div> </div>
</dialog> </dialog>
@@ -746,7 +752,7 @@
<div class="dialog-header"> <div class="dialog-header">
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3> <h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
</div> </div>
<form id="folderForm" onsubmit="saveFolder(event)"> <form id="folderForm" data-onsubmit="saveFolder(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="folderIsEdit"> <input type="hidden" id="folderIsEdit">
<input type="hidden" id="folderOriginalId"> <input type="hidden" id="folderOriginalId">
@@ -773,7 +779,7 @@
</label> </label>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button> <button type="button" class="btn-secondary" data-onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button> <button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button>
</div> </div>
</form> </form>
@@ -813,7 +819,7 @@
</ul> </ul>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button> <button type="button" class="btn-secondary" data-onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
</div> </div>
</dialog> </dialog>
+72
View File
@@ -63,6 +63,8 @@ import {
import { import {
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange, loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
onDisplayContrastInput, onDisplayContrastChange,
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
toggleDisplayPower, loadHeaderLinks, loadLinksTable, toggleDisplayPower, loadHeaderLinks, loadLinksTable,
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm, showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
linkFormDirty, setLinkFormDirty, linkFormDirty, setLinkFormDirty,
@@ -72,6 +74,10 @@ import {
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors, toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
} from './background.js'; } from './background.js';
import {
updateForegroundUI, loadForegroundProcess,
} from './foreground.js';
// ============================================================ // ============================================================
// Register late-bound callbacks for core's updateAllText() // Register late-bound callbacks for core's updateAllText()
// ============================================================ // ============================================================
@@ -127,11 +133,15 @@ Object.assign(window, {
saveLink, deleteLinkConfirm, saveLink, deleteLinkConfirm,
// Display // Display
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange, loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
onDisplayContrastInput, onDisplayContrastChange,
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
toggleDisplayPower, toggleDisplayPower,
// Audio device // Audio device
onAudioDeviceChanged, onAudioDeviceChanged,
// About // About
showAboutDialog, closeAboutDialog, showAboutDialog, closeAboutDialog,
// Foreground
loadForegroundProcess,
}); });
// ============================================================ // ============================================================
@@ -155,10 +165,72 @@ HTMLDialogElement.prototype.showModal = function (...args) {
return result; return result;
}; };
// CSP-safe replacement for inline on* handlers. HTML uses data-onclick,
// data-onchange, data-oninput, data-onsubmit with simple call expressions
// like "fn()", "fn('arg')", "fn(event)". We parse those at startup and
// attach proper addEventListener calls so script-src 'self' stays strict.
const INLINE_HANDLER_EVENTS = {
'data-onclick': 'click',
'data-onchange': 'change',
'data-oninput': 'input',
'data-onsubmit': 'submit',
};
function parseInlineHandlerArg(token) {
const t = token.trim();
if (t === '') return { kind: 'empty' };
if (t === 'event') return { kind: 'event' };
if (t === 'true') return { kind: 'literal', value: true };
if (t === 'false') return { kind: 'literal', value: false };
if (t === 'null') return { kind: 'literal', value: null };
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: 'literal', value: Number(t) };
if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
return { kind: 'literal', value: t.slice(1, -1) };
}
console.warn('inline-handler: unsupported arg token', token);
return { kind: 'literal', value: undefined };
}
function compileInlineHandler(expr) {
const m = expr.match(/^\s*([A-Za-z_$][\w$]*)\s*\((.*)\)\s*;?\s*$/s);
if (!m) {
console.warn('inline-handler: unparsable expression', expr);
return null;
}
const fnName = m[1];
const argsRaw = m[2].trim();
const argTokens = argsRaw === '' ? [] : argsRaw.split(',').map(s => s.trim());
const parsedArgs = argTokens.map(parseInlineHandlerArg);
return function (event) {
const fn = window[fnName];
if (typeof fn !== 'function') {
console.error('inline-handler: missing global function', fnName);
return;
}
const args = parsedArgs.map(a => a.kind === 'event' ? event : a.value);
return fn.apply(this, args);
};
}
function wireInlineHandlers(root) {
for (const [attr, eventName] of Object.entries(INLINE_HANDLER_EVENTS)) {
const nodes = root.querySelectorAll(`[${attr}]`);
for (const el of nodes) {
const expr = el.getAttribute(attr);
const handler = compileInlineHandler(expr);
if (handler) el.addEventListener(eventName, handler);
el.removeAttribute(attr);
}
}
}
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
// Cache DOM references // Cache DOM references
cacheDom(); cacheDom();
// Wire CSP-safe inline-handler stand-ins from index.html
wireInlineHandlers(document);
// Initialize theme and accent color // Initialize theme and accent color
initTheme(); initTheme();
initAccentColor(); initAccentColor();
+50 -20
View File
@@ -66,12 +66,14 @@ function showRootFolders() {
// Hide search at root level // Hide search at root level
showBrowserSearch(false); showBrowserSearch(false);
// Render breadcrumb with just "Home" (not clickable at root) // Render breadcrumb with just "Home" (already at root — not interactive).
const breadcrumb = document.getElementById('breadcrumb'); const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = ''; breadcrumb.innerHTML = '';
const root = document.createElement('span'); const root = document.createElement('span');
root.className = 'breadcrumb-item breadcrumb-home'; root.className = 'breadcrumb-item breadcrumb-home';
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>'; root.setAttribute('aria-current', 'page');
root.setAttribute('aria-label', t('browser.home') || 'Home');
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
breadcrumb.appendChild(root); breadcrumb.appendChild(root);
// Hide play all button and pagination // Hide play all button and pagination
@@ -133,8 +135,10 @@ function showRootFolders() {
} }
async function browsePath(folderId, path, offset = 0, nocache = false) { async function browsePath(folderId, path, offset = 0, nocache = false) {
// Clear search when navigating // Clear search when navigating; bump browse generation so in-flight
// thumbnail fetches from the previous folder can be discarded.
showBrowserSearch(false); showBrowserSearch(false);
bumpBrowseGen();
try { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
@@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) {
const parts = (currentPathStr || '').split('/').filter(p => p); const parts = (currentPathStr || '').split('/').filter(p => p);
let path = '/'; let path = '/';
// Home link (back to folder list) // Home link (back to folder list) — use a real <button> so it's
const home = document.createElement('span'); // keyboard-focusable and reachable by screen readers.
const home = document.createElement('button');
home.type = 'button';
home.className = 'breadcrumb-item breadcrumb-home'; home.className = 'breadcrumb-item breadcrumb-home';
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>'; home.setAttribute('aria-label', t('browser.home') || 'Home');
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
home.onclick = () => showRootFolders(); home.onclick = () => showRootFolders();
breadcrumb.appendChild(home); breadcrumb.appendChild(home);
@@ -512,16 +519,34 @@ function formatBitrate(bps) {
return Math.round(bps / 1000) + ' kbps'; return Math.round(bps / 1000) + ' kbps';
} }
// Bump this whenever the user changes folder/path so in-flight fetches from
// the previous view can be ignored when they finally resolve.
let _browseGen = 0;
function bumpBrowseGen() { return ++_browseGen; }
function currentBrowseGen() { return _browseGen; }
function buildRelativeFilePath(relativePath, fileName) {
const base = (relativePath === '/' || relativePath === '') ? '' : relativePath.replace(/\/$/, '');
return base + '/' + fileName;
}
async function loadThumbnail(imgElement, fileName) { async function loadThumbnail(imgElement, fileName) {
const myGen = currentBrowseGen();
const folderId = currentFolderId;
const relPath = buildRelativeFilePath(currentPath, fileName);
const cacheKey = `${folderId}|${relPath}`;
try { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
// Note: the imgElement is intentionally NOT in the DOM yet when
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); // renderBrowserGrid/renderBrowserList call us — it's still inside a
// detached wrapper. Don't bail on isConnected here; rely on the
// post-await checks below, which correctly catch navigation away.
// Check cache first // Check cache first
if (thumbnailCache.has(absolutePath)) { if (thumbnailCache.has(cacheKey)) {
const cachedUrl = thumbnailCache.get(absolutePath); const cachedUrl = thumbnailCache.get(cacheKey);
imgElement.onload = () => { imgElement.onload = () => {
if (!imgElement.isConnected) return;
imgElement.classList.remove('loading'); imgElement.classList.remove('loading');
imgElement.classList.add('loaded'); imgElement.classList.add('loaded');
}; };
@@ -529,17 +554,24 @@ async function loadThumbnail(imgElement, fileName) {
return; return;
} }
const encodedPath = encodeURIComponent(absolutePath); const params = new URLSearchParams({
folder_id: folderId,
path: relPath,
size: 'medium',
});
const response = await fetch( const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`, `/api/browser/thumbnail?${params.toString()}`,
{ headers: getAuthHeaders() } { headers: getAuthHeaders() }
); );
// Drop the response if the user has since navigated away.
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
if (response.status === 200) { if (response.status === 200) {
const blob = await response.blob(); const blob = await response.blob();
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
thumbnailCache.set(absolutePath, url); thumbnailCache.set(cacheKey, url);
// Evict oldest entries when cache exceeds limit // Evict oldest entries when cache exceeds limit
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) { if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
@@ -548,13 +580,11 @@ async function loadThumbnail(imgElement, fileName) {
thumbnailCache.delete(oldest); thumbnailCache.delete(oldest);
} }
// Wait for image to actually load before showing it
imgElement.onload = () => { imgElement.onload = () => {
imgElement.classList.remove('loading'); imgElement.classList.remove('loading');
imgElement.classList.add('loaded'); imgElement.classList.add('loaded');
}; };
// Revoke previous blob URL if not managed by cache
if (imgElement.src && imgElement.src.startsWith('blob:')) { if (imgElement.src && imgElement.src.startsWith('blob:')) {
let isCached = false; let isCached = false;
for (const cachedUrl of thumbnailCache.values()) { for (const cachedUrl of thumbnailCache.values()) {
@@ -564,8 +594,8 @@ async function loadThumbnail(imgElement, fileName) {
} }
imgElement.src = url; imgElement.src = url;
} else { } else {
// Fallback to icon (204 = no thumbnail available)
const parent = imgElement.parentElement; const parent = imgElement.parentElement;
if (!parent) return;
const isList = parent.classList.contains('browser-list-icon'); const isList = parent.classList.contains('browser-list-icon');
imgElement.remove(); imgElement.remove();
if (isList) { if (isList) {
@@ -579,7 +609,7 @@ async function loadThumbnail(imgElement, fileName) {
} }
} catch (error) { } catch (error) {
console.error('Error loading thumbnail:', error); console.error('Error loading thumbnail:', error);
imgElement.classList.remove('loading'); if (imgElement.isConnected) imgElement.classList.remove('loading');
} }
} }
@@ -601,12 +631,12 @@ async function playMediaFile(fileName) {
try { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); const relativePath = buildRelativeFilePath(currentPath, fileName);
const response = await fetch('/api/browser/play', { const response = await fetch('/api/browser/play', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ path: absolutePath }) body: JSON.stringify({ folder_id: currentFolderId, path: relativePath })
}); });
if (!response.ok) throw new Error('Failed to play file'); if (!response.ok) throw new Error('Failed to play file');
+13 -12
View File
@@ -81,7 +81,7 @@ async function _loadCallbacksTableImpl() {
`).join(''); `).join('');
} catch (error) { } catch (error) {
console.error('Error loading callbacks:', error); console.error('Error loading callbacks:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>'; tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('callbacks.msg.load_failed'))}</td></tr>`;
} }
} }
@@ -121,7 +121,7 @@ export async function showEditCallbackDialog(callbackName) {
const callback = callbacksList.find(c => c.name === callbackName); const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) { if (!callback) {
showToast('Callback not found', 'error'); showToast(t('callbacks.msg.not_found'), 'error');
return; return;
} }
@@ -142,7 +142,7 @@ export async function showEditCallbackDialog(callbackName) {
dialog.showModal(); dialog.showModal();
} catch (error) { } catch (error) {
console.error('Error loading callback for edit:', error); console.error('Error loading callback for edit:', error);
showToast('Failed to load callback details', 'error'); showToast(t('callbacks.msg.load_failed'), 'error');
} }
} }
@@ -175,9 +175,10 @@ export async function saveCallback(event) {
shell: true shell: true
}; };
const encodedName = encodeURIComponent(callbackName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` : `/api/callbacks/update/${encodedName}` :
`/api/callbacks/create/${callbackName}`; `/api/callbacks/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -191,16 +192,16 @@ export async function saveCallback(event) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
callbackFormDirty = false; callbackFormDirty = false;
closeCallbackDialog(); closeCallbackDialog();
loadCallbacksTable(); loadCallbacksTable();
} else { } else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error'); showToast(result.detail || t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Error saving callback:', error); console.error('Error saving callback:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error'); showToast(t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
} finally { } finally {
if (submitBtn) submitBtn.disabled = false; if (submitBtn) submitBtn.disabled = false;
} }
@@ -212,7 +213,7 @@ export async function deleteCallbackConfirm(callbackName) {
} }
try { try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, { const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
@@ -220,13 +221,13 @@ export async function deleteCallbackConfirm(callbackName) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success'); showToast(t('callbacks.msg.deleted'), 'success');
loadCallbacksTable(); loadCallbacksTable();
} else { } else {
showToast(result.detail || 'Failed to delete callback', 'error'); showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Error deleting callback:', error); console.error('Error deleting callback:', error);
showToast('Error deleting callback', 'error'); showToast(t('callbacks.msg.delete_failed'), 'error');
} }
} }
+59 -7
View File
@@ -155,6 +155,7 @@ export const VOLUME_RELEASE_DELAY_MS = 500;
// Shared state (accessed across multiple modules) // Shared state (accessed across multiple modules)
export let ws = null; export let ws = null;
export function setWs(value) { ws = value; } export function setWs(value) { ws = value; }
export function getWs() { return ws; }
export let currentState = 'idle'; export let currentState = 'idle';
export function setCurrentState(value) { currentState = value; } export function setCurrentState(value) { currentState = value; }
export let currentDuration = 0; export let currentDuration = 0;
@@ -513,6 +514,12 @@ export function setVolume(volume) {
sendCommand('volume', { volume: volume }); sendCommand('volume', { volume: volume });
} }
} }
// Reset the de-dupe cache whenever the server reports a fresh volume value
// (e.g., another client moved the slider). Otherwise the user can end up
// unable to "set volume back to the value we last sent" after a remote change.
export function notifyRemoteVolume(volume) {
lastSentVolume = volume;
}
export function toggleMute() { export function toggleMute() {
sendCommand('mute'); sendCommand('mute');
@@ -536,23 +543,68 @@ function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {} try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
} }
// Strict iconify MDI slug — used to reject anything that could be path-traversal
// or query injection before we even hit the network.
const MDI_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
function sanitizeSvg(rawSvg) {
// Parse the SVG and strip anything that could execute script. Anything
// unparseable returns null so callers fall back to the placeholder.
try {
const doc = new DOMParser().parseFromString(rawSvg, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.tagName.toLowerCase() !== 'svg' || root.querySelector('parsererror')) {
return null;
}
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
const toRemove = [];
let node = walker.currentNode;
while (node) {
const tag = node.tagName?.toLowerCase();
if (tag === 'script' || tag === 'foreignobject') {
toRemove.push(node);
} else if (node.attributes) {
for (const attr of Array.from(node.attributes)) {
const name = attr.name.toLowerCase();
if (name.startsWith('on') ||
((name === 'href' || name === 'xlink:href') &&
/^\s*(javascript|data):/i.test(attr.value))) {
node.removeAttribute(attr.name);
}
}
}
node = walker.nextNode();
}
toRemove.forEach((el) => el.remove());
return root.outerHTML;
} catch {
return null;
}
}
const PLACEHOLDER_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
export async function fetchMdiIcon(iconName) { export async function fetchMdiIcon(iconName) {
const name = iconName.replace(/^mdi:/, ''); const name = String(iconName || '').replace(/^mdi:/, '');
if (!MDI_SLUG_RE.test(name)) return PLACEHOLDER_SVG;
if (mdiIconCache[name]) return mdiIconCache[name]; if (mdiIconCache[name]) return mdiIconCache[name];
try { try {
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`); const response = await fetch(`https://api.iconify.design/mdi/${encodeURIComponent(name)}.svg?width=16&height=16`);
if (response.ok) { if (response.ok) {
const svg = await response.text(); const raw = await response.text();
mdiIconCache[name] = svg; const safe = sanitizeSvg(raw);
_persistMdiCache(); if (safe) {
return svg; mdiIconCache[name] = safe;
_persistMdiCache();
return safe;
}
} }
} catch (e) { } catch (e) {
console.warn('Failed to fetch MDI icon:', name, e); console.warn('Failed to fetch MDI icon:', name, e);
} }
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>'; return PLACEHOLDER_SVG;
} }
export async function resolveMdiIcons(container) { export async function resolveMdiIcons(container) {
+188
View File
@@ -0,0 +1,188 @@
// ============================================================
// Foreground: Currently-focused desktop process card (rendered at
// the top of the Display tab)
// ============================================================
import { t } from './core.js';
let latestForeground = null;
let agoTickTimer = null;
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatAgo(epoch) {
if (!epoch) return '';
const now = Date.now() / 1000;
const diff = Math.max(0, now - epoch);
if (diff < 60) {
return t('foreground.ago.seconds', { n: Math.floor(diff) });
}
if (diff < 3600) {
return t('foreground.ago.minutes', { n: Math.floor(diff / 60) });
}
if (diff < 86400) {
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
return t('foreground.ago.hours', { n: h, m: m });
}
return t('foreground.ago.days', { n: Math.floor(diff / 86400) });
}
function formatGeometry(g) {
if (!g) return '—';
const w = g.width ?? (g.right - g.left);
const h = g.height ?? (g.bottom - g.top);
return `${w}×${h} @ (${g.left}, ${g.top})`;
}
function truncatePath(p, max = 64) {
if (!p) return '';
if (p.length <= max) return p;
// Keep the tail (filename) visible — that's the part the user cares about.
return '…' + p.slice(-(max - 1));
}
function renderEmpty(message, errorMsg) {
const stage = document.getElementById('foregroundStage');
if (!stage) return;
stage.innerHTML = `
<div class="empty-state-illustration foreground-empty">
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
<p>${escapeHtml(message)}</p>
${errorMsg ? `<p class="foreground-empty-error">${escapeHtml(errorMsg)}</p>` : ''}
</div>
`;
}
function renderTile(data) {
const stage = document.getElementById('foregroundStage');
if (!stage) return;
const procName = data.process_name || '—';
const winTitle = data.window_title || '';
const execPath = data.executable_path || '';
const pid = data.pid ?? '—';
const startedEpoch = data.started_at;
const startedAgo = startedEpoch ? formatAgo(startedEpoch) : '—';
const startedAbs = startedEpoch
? new Date(startedEpoch * 1000).toLocaleString()
: '';
const geom = formatGeometry(data.window_geometry);
const platform = data.platform || '—';
const monitorId = data.monitor_id;
// Chips: only render ones that apply
const chips = [];
if (data.is_fullscreen) {
chips.push(`<span class="fg-chip fg-chip-accent">${escapeHtml(t('foreground.fullscreen'))}</span>`);
} else if (!data.is_minimized) {
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.windowed'))}</span>`);
}
if (data.is_minimized) {
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.minimized'))}</span>`);
}
if (monitorId !== null && monitorId !== undefined) {
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.monitor', { n: monitorId + 1 }))}</span>`);
}
if (data.is_browser) {
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.browser'))}</span>`);
}
// Optional browser-only detail rows (page title + URL when available)
const browserRows = [];
if (data.is_browser) {
if (data.browser_page_title) {
browserRows.push(`
<div class="fg-row">
<dt>${escapeHtml(t('foreground.page_title'))}</dt>
<dd title="${escapeHtml(data.browser_page_title)}">${escapeHtml(data.browser_page_title)}</dd>
</div>
`);
}
if (data.browser_url) {
browserRows.push(`
<div class="fg-row">
<dt>${escapeHtml(t('foreground.url'))}</dt>
<dd title="${escapeHtml(data.browser_url)}"><span class="fg-mono">${escapeHtml(truncatePath(data.browser_url, 80))}</span></dd>
</div>
`);
}
}
stage.innerHTML = `
<article class="foreground-card" data-fullscreen="${data.is_fullscreen ? '1' : '0'}">
<div class="fg-kicker">
<span data-i18n="foreground.kicker">Foreground</span>
</div>
<h1 class="fg-process" title="${escapeHtml(procName)}">${escapeHtml(procName)}</h1>
<div class="fg-window-title" title="${escapeHtml(winTitle)}">${escapeHtml(winTitle)}</div>
<div class="fg-chips">${chips.join('')}</div>
<dl class="fg-details">
${browserRows.join('')}
<div class="fg-row">
<dt>${escapeHtml(t('foreground.executable'))}</dt>
<dd title="${escapeHtml(execPath)}"><span class="fg-mono">${escapeHtml(truncatePath(execPath))}</span></dd>
</div>
<div class="fg-row">
<dt>${escapeHtml(t('foreground.pid'))}</dt>
<dd><span class="fg-mono">${escapeHtml(String(pid))}</span></dd>
</div>
<div class="fg-row">
<dt>${escapeHtml(t('foreground.started'))}</dt>
<dd title="${escapeHtml(startedAbs)}"><span class="fg-ago" data-started="${startedEpoch ?? ''}">${escapeHtml(startedAgo)}</span></dd>
</div>
<div class="fg-row">
<dt>${escapeHtml(t('foreground.geometry'))}</dt>
<dd><span class="fg-mono">${escapeHtml(geom)}</span></dd>
</div>
<div class="fg-row">
<dt>${escapeHtml(t('foreground.platform'))}</dt>
<dd>${escapeHtml(platform)}</dd>
</div>
</dl>
</article>
`;
}
function startAgoTicker() {
if (agoTickTimer) return;
agoTickTimer = setInterval(() => {
const el = document.querySelector('.fg-ago[data-started]');
if (!el) return;
const epoch = parseFloat(el.getAttribute('data-started'));
if (!epoch) return;
el.textContent = formatAgo(epoch);
}, 15000);
}
export function updateForegroundUI(data) {
latestForeground = data;
if (!data || data.available === false) {
const errMsg = data && data.error ? data.error : '';
renderEmpty(t('foreground.unavailable'), errMsg);
} else if (!data.process_name && !data.pid) {
renderEmpty(t('foreground.no_process'), '');
} else {
renderTile(data);
startAgoTicker();
}
}
export function loadForegroundProcess() {
// Push-only — just render the cached state. If nothing has arrived
// yet, leave the loading placeholder visible.
if (latestForeground !== null) {
updateForegroundUI(latestForeground);
}
}
+383 -21
View File
@@ -1,12 +1,107 @@
// ============================================================ // ============================================================
// Display Brightness & Power Control + Links Management // Display Brightness, Power, Contrast, Input Source, Color Preset,
// Picture Mode Control + Links Management
// ============================================================ // ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js'; import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
import { IconSelect } from './icon-select.js';
let displayBrightnessTimers = {}; let displayBrightnessTimers = {};
let displayContrastTimers = {};
let _displayIconSelects = [];
const DISPLAY_THROTTLE_MS = 50; const DISPLAY_THROTTLE_MS = 50;
// ─── Icon palette for the tuning IconSelects ───────────────────────────
// All SVGs are 24x24 monochrome — IconSelect's CSS fills them with currentColor.
const ICON_PORT_GENERIC =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8h16v8H4V8zm2 2v4h12v-4H6zm2 1h2v2H8v-2zm4 0h2v2h-2v-2zm-9 6h18v2H3v-2z"/></svg>';
const ICON_PORT_HDMI =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 9l2-2h14l2 2v5l-2 2h-3l-1 1H9l-1-1H5l-2-2V9zm2.5.5v4l1 1h2l1 1h7l1-1h2l1-1v-4l-1-.5H6.5l-1 .5z"/></svg>';
const ICON_PORT_DP =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8l2-2h12l2 2v8l-2 2H6l-2-2V8zm2 .5V15l1 1h10l1-1V8.5L17 8H7l-1 .5zM8 10h2v4H8v-4zm6 0h2v4h-2v-4z"/></svg>';
const ICON_PORT_DVI =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 8h18v8H3V8zm2 1.5v5h14v-5H5zM7 11h1.5v2H7v-2zm3 0h1.5v2H10v-2zm3 0h1.5v2H13v-2zm3 0h1.5v2H16v-2z"/></svg>';
const ICON_PORT_VGA =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 7h14a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2zm0 2v6h14V9H5zm2 1.5a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5z"/></svg>';
const ICON_PORT_USBC =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 10a3 3 0 013-3h8a3 3 0 013 3v4a3 3 0 01-3 3H8a3 3 0 01-3-3v-4zm3-1.5A1.5 1.5 0 006.5 10v4A1.5 1.5 0 008 15.5h8a1.5 1.5 0 001.5-1.5v-4A1.5 1.5 0 0016 8.5H8zm1 2h6v3H9v-3z"/></svg>';
const ICON_THERMOMETER =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a3 3 0 00-3 3v8.17A4 4 0 1015 14.17V6a3 3 0 00-3-3zm-1.5 3a1.5 1.5 0 113 0v8.76a2.5 2.5 0 11-3 0V6zm1.5 5a1 1 0 011 1v2.27a1.5 1.5 0 11-2 0V12a1 1 0 011-1z"/></svg>';
const ICON_MODE_MOVIE =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 5h16v14H4V5zm2 2v2h2V7H6zm0 4v2h2v-2H6zm0 4v2h2v-2H6zm10-8v2h2V7h-2zm0 4v2h2v-2h-2zm0 4v2h2v-2h-2zm-6-7h4v8h-4V8z"/></svg>';
const ICON_MODE_GAME =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 8a5 5 0 00-5 5 4 4 0 007.4 2.1L11 14h2l1.6 1.1A4 4 0 0022 13a5 5 0 00-5-5H7zm1 3v1H7v-1H6v-1h1V9h1v1h1v1H8zm7 0a1 1 0 110-2 1 1 0 010 2zm2 2a1 1 0 110-2 1 1 0 010 2z"/></svg>';
const ICON_MODE_SPORT =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 2c1.7 0 3.3.5 4.6 1.4l-1.4 2.4L12 6.5l-3.2 1.3-1.4-2.4A8 8 0 0112 4zm-7.6 4l2.5 1.3-.5 3.5L4 16.4A8 8 0 014.4 8zm15.2 0a8 8 0 01.4 8.4l-2.4-1.6-.5-3.5L19.6 8zM12 8.7l3 1.2.6 3.2L13 15h-2l-2.6-1.9.6-3.2L12 8.7zm-5.3 8.8L9 16.5l2.4 1h1.2l2.4-1 2.3 1A8 8 0 016.7 17.5z"/></svg>';
const ICON_MODE_PRO =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 4h18v12H13v2h4v2H7v-2h4v-2H3V4zm2 2v8h14V6H5zm2 2h6v2H7V8zm0 3h10v2H7v-2z"/></svg>';
const ICON_MODE_DOCS =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M6 3h9l4 4v14H6V3zm2 2v14h10V9h-4V5H8zm2 6h6v2h-6v-2zm0 3h6v2h-6v-2z"/></svg>';
const ICON_MODE_USER =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a4 4 0 100 8 4 4 0 000-8zm0 2a2 2 0 110 4 2 2 0 010-4zm0 8c-3.3 0-7 1.5-7 4.5V20h14v-2.5c0-3-3.7-4.5-7-4.5z"/></svg>';
const ICON_MODE_DEFAULT =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v12H3V5zm2 2v8h14V7H5zm-2 12h18v2H3v-2z"/></svg>';
const ICON_MODE_MIXED =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v14H3V5zm2 2v10h7V7H5zm9 0v10h5V7h-5z"/></svg>';
function inputSourceIcon(src) {
const s = String(src || '').toUpperCase();
if (s.startsWith('HDMI')) return ICON_PORT_HDMI;
if (s.startsWith('DP')) return ICON_PORT_DP;
if (s.startsWith('DVI')) return ICON_PORT_DVI;
if (s.startsWith('VGA')) return ICON_PORT_VGA;
if (s.startsWith('USB')) return ICON_PORT_USBC;
return ICON_PORT_GENERIC;
}
function pictureModeIcon(label) {
const k = String(label || '').toLowerCase();
if (k.includes('movie')) return ICON_MODE_MOVIE;
if (k.includes('game')) return ICON_MODE_GAME;
if (k.includes('sport')) return ICON_MODE_SPORT;
if (k.includes('professional')) return ICON_MODE_PRO;
if (k.includes('productivity')) return ICON_MODE_DOCS;
if (k.includes('user')) return ICON_MODE_USER;
if (k.includes('mixed')) return ICON_MODE_MIXED;
return ICON_MODE_DEFAULT;
}
// Humanise enum-style identifiers returned by monitorcontrol so users
// don't see SHOUT_CASE strings in the UI.
function humanizeInputSource(raw) {
if (!raw) return '';
// OFF / RESERVED → "Off" / "Reserved"
// VGA1 → "VGA 1", HDMI1 → "HDMI 1", DP1 → "DisplayPort 1"
const map = { DP: 'DisplayPort', DVI: 'DVI', HDMI: 'HDMI', VGA: 'VGA', USBC: 'USB-C' };
const m = String(raw).toUpperCase().match(/^(DP|DVI|HDMI|VGA|USBC|USB_C)(\d*)$/);
if (m) {
const key = m[1] === 'USB_C' ? 'USBC' : m[1];
return `${map[key]}${m[2] ? ' ' + m[2] : ''}`;
}
return String(raw)
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase());
}
function humanizeColorPreset(raw) {
if (!raw) return '';
// COLOR_TEMP_6500K → "6500 K", COLOR_TEMP_NATIVE → "Native",
// COLOR_TEMP_USER1 → "User 1"
const s = String(raw).replace(/^COLOR_TEMP_?/i, '');
const kelvin = s.match(/^(\d{4,5})K?$/);
if (kelvin) return `${kelvin[1]} K`;
const user = s.match(/^USER\s*_?(\d+)$/i);
if (user) return `User ${user[1]}`;
return s
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase());
}
export async function loadDisplayMonitors() { export async function loadDisplayMonitors() {
if (!hasCredentials()) return; if (!hasCredentials()) return;
@@ -14,7 +109,7 @@ export async function loadDisplayMonitors() {
if (!container) return; if (!container) return;
try { try {
const response = await fetch('/api/display/monitors?refresh=true', { const response = await fetch('/api/display/monitors', {
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
@@ -36,7 +131,13 @@ export async function loadDisplayMonitors() {
return; return;
} }
// Destroy IconSelects from a previous render so listeners + popups
// don't pile up.
_displayIconSelects.forEach(inst => { try { inst.destroy(); } catch (_) {} });
_displayIconSelects = [];
container.innerHTML = ''; container.innerHTML = '';
const pendingIconSelects = [];
monitors.forEach(monitor => { monitors.forEach(monitor => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'display-monitor-card'; card.className = 'display-monitor-card';
@@ -47,16 +148,19 @@ export async function loadDisplayMonitors() {
let powerBtn = ''; let powerBtn = '';
if (monitor.power_supported) { if (monitor.power_supported) {
// Inline onclick with string-interpolated monitor.name is a DOM-XSS
// foot-gun if the OS ever reports a name containing quotes / angle
// brackets. Use a delegated click handler bound to data-* attrs.
powerBtn = ` powerBtn = `
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}" <button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')" data-action="toggle-power" data-monitor-id="${monitor.id}"
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}"> title="${escapeHtml(monitor.power_on ? t('display.power_off') : t('display.power_on'))}">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
</button>`; </button>`;
} }
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 '); const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : ''; const detailsHtml = details ? `<span class="display-monitor-details">${escapeHtml(details)}</span>` : '';
const primaryBadge = monitor.is_primary const primaryBadge = monitor.is_primary
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}"> ? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"> <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -65,29 +169,169 @@ export async function loadDisplayMonitors() {
</span>` </span>`
: ''; : '';
// Contrast (DDC/CI) — render only if the monitor reports it.
let contrastRow = '';
if (monitor.contrast_supported) {
const contrastValue = monitor.contrast !== null && monitor.contrast !== undefined
? monitor.contrast : 50;
contrastRow = `
<div class="display-slider-row">
<svg class="display-slider-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18V4c4.41 0 8 3.59 8 8s-3.59 8-8 8z"/>
</svg>
<span class="display-slider-label" data-i18n="display.contrast">${t('display.contrast')}</span>
<input type="range" class="display-slider display-contrast-slider"
min="0" max="100" value="${contrastValue}"
data-display-slider="contrast" data-monitor-id="${monitor.id}">
<span class="display-slider-value" id="contrast-val-${monitor.id}">${contrastValue}%</span>
</div>`;
}
// Build the picture-tuning selects (input source / color preset / picture mode).
const tuningRows = [];
// Each tuning field renders a hidden <select> (state holder)
// which IconSelect then enhances after the card lands in the DOM.
const tuningTargets = [];
if (monitor.input_source_supported && monitor.available_input_sources.length > 0) {
const current = monitor.input_source;
const options = monitor.available_input_sources.map(src => {
const selected = src === current ? 'selected' : '';
return `<option value="${escapeHtml(src)}" ${selected}>${escapeHtml(humanizeInputSource(src))}</option>`;
}).join('');
tuningRows.push(`
<div class="display-tuning-field">
<span class="display-tuning-label" data-i18n="display.input_source">${t('display.input_source')}</span>
<select data-display-select="input" data-monitor-id="${monitor.id}"
aria-label="${t('display.input_source')}">
${options}
</select>
</div>`);
tuningTargets.push({
kind: 'input',
monitorId: monitor.id,
items: monitor.available_input_sources.map(src => ({
value: src,
icon: inputSourceIcon(src),
label: humanizeInputSource(src),
})),
});
}
if (monitor.color_preset_supported && monitor.available_color_presets.length > 0) {
const current = monitor.color_preset;
const options = monitor.available_color_presets.map(p => {
const selected = p === current ? 'selected' : '';
return `<option value="${escapeHtml(p)}" ${selected}>${escapeHtml(humanizeColorPreset(p))}</option>`;
}).join('');
tuningRows.push(`
<div class="display-tuning-field">
<span class="display-tuning-label" data-i18n="display.color_preset">${t('display.color_preset')}</span>
<select data-display-select="color" data-monitor-id="${monitor.id}"
aria-label="${t('display.color_preset')}">
${options}
</select>
</div>`);
tuningTargets.push({
kind: 'color',
monitorId: monitor.id,
items: monitor.available_color_presets.map(p => ({
value: p,
icon: ICON_THERMOMETER,
label: humanizeColorPreset(p),
})),
});
}
if (monitor.picture_mode_supported && monitor.available_picture_modes.length > 0) {
const current = monitor.picture_mode_code;
const options = monitor.available_picture_modes.map(m => {
const selected = m.code === current ? 'selected' : '';
return `<option value="${m.code}" ${selected}>${escapeHtml(m.label)}</option>`;
}).join('');
tuningRows.push(`
<div class="display-tuning-field">
<span class="display-tuning-label" data-i18n="display.picture_mode">${t('display.picture_mode')}</span>
<select data-display-select="mode" data-monitor-id="${monitor.id}"
aria-label="${t('display.picture_mode')}">
${options}
</select>
</div>`);
tuningTargets.push({
kind: 'mode',
monitorId: monitor.id,
items: monitor.available_picture_modes.map(m => ({
value: String(m.code),
icon: pictureModeIcon(m.label),
label: m.label,
})),
});
}
pendingIconSelects.push(...tuningTargets);
const tuningBlock = tuningRows.length > 0
? `<div class="display-tuning">
<div class="display-tuning-title" data-i18n="display.tuning">${t('display.tuning')}</div>
<div class="display-tuning-grid">${tuningRows.join('')}</div>
</div>`
: '';
card.innerHTML = ` card.innerHTML = `
<div class="display-monitor-header"> <div class="display-monitor-header">
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20"> <svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/> <path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
</svg> </svg>
<div class="display-monitor-info"> <div class="display-monitor-info">
<span class="display-monitor-name">${monitor.name}${primaryBadge}</span> <span class="display-monitor-name"><span class="display-monitor-name-text">${escapeHtml(monitor.name)}</span>${primaryBadge}</span>
${detailsHtml} ${detailsHtml}
</div> </div>
${powerBtn} ${powerBtn}
</div> </div>
<div class="display-brightness-control"> <div class="display-slider-row display-brightness-control">
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16"> <svg class="display-slider-icon display-brightness-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/> <path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
</svg> </svg>
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled} <span class="display-slider-label" data-i18n="display.brightness">${t('display.brightness')}</span>
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)" <input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)"> data-display-slider="brightness" data-monitor-id="${monitor.id}">
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span> <span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
</div>`; </div>
${contrastRow}
${tuningBlock}`;
container.appendChild(card); container.appendChild(card);
}); });
// Bind a single delegated click handler for the power buttons,
// plus input/change handlers for the brightness & contrast sliders.
// Avoids inline on* attributes (blocked by script-src 'self' CSP).
container.removeEventListener('click', _onPowerButtonClick);
container.addEventListener('click', _onPowerButtonClick);
container.removeEventListener('input', _onDisplaySliderInput);
container.addEventListener('input', _onDisplaySliderInput);
container.removeEventListener('change', _onDisplaySliderChange);
container.addEventListener('change', _onDisplaySliderChange);
// Enhance every tuning <select> with an IconSelect now that the
// cards are in the DOM (IconSelect needs offsetParent + sibling).
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
const sel = container.querySelector(
`select[data-display-select="${kind}"][data-monitor-id="${monitorId}"]`
);
if (!sel) return;
const handler = kind === 'input' ? onDisplayInputSourceChange
: kind === 'color' ? onDisplayColorPresetChange
: onDisplayPictureModeChange;
_displayIconSelects.push(new IconSelect({
target: sel,
items,
columns: 1,
horizontal: true,
onChange: (value) => handler(monitorId, value),
}));
});
} catch (e) { } catch (e) {
console.error('Failed to load display monitors:', e); console.error('Failed to load display monitors:', e);
} }
@@ -124,7 +368,122 @@ async function sendDisplayBrightness(monitorId, brightness) {
} }
} }
export async function toggleDisplayPower(monitorId, monitorName) { export function onDisplayContrastInput(monitorId, value) {
const label = document.getElementById(`contrast-val-${monitorId}`);
if (label) label.textContent = `${value}%`;
if (displayContrastTimers[monitorId]) clearTimeout(displayContrastTimers[monitorId]);
displayContrastTimers[monitorId] = setTimeout(() => {
sendDisplayContrast(monitorId, parseInt(value));
displayContrastTimers[monitorId] = null;
}, DISPLAY_THROTTLE_MS);
}
export function onDisplayContrastChange(monitorId, value) {
if (displayContrastTimers[monitorId]) {
clearTimeout(displayContrastTimers[monitorId]);
displayContrastTimers[monitorId] = null;
}
sendDisplayContrast(monitorId, parseInt(value));
}
async function sendDisplayContrast(monitorId, contrast) {
try {
const r = await fetch(`/api/display/contrast/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ contrast })
});
const data = await r.json().catch(() => ({}));
if (!data.success) showToast(t('display.msg.contrast_failed'), 'error');
} catch (e) {
console.error('Failed to set contrast:', e);
showToast(t('display.msg.contrast_failed'), 'error');
}
}
export async function onDisplayInputSourceChange(monitorId, source) {
try {
const r = await fetch(`/api/display/input_source/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ source })
});
const data = await r.json().catch(() => ({}));
if (data.success) showToast(t('display.msg.input_changed'), 'success');
else showToast(t('display.msg.input_failed'), 'error');
} catch (e) {
console.error('Failed to set input source:', e);
showToast(t('display.msg.input_failed'), 'error');
}
}
export async function onDisplayColorPresetChange(monitorId, preset) {
try {
const r = await fetch(`/api/display/color_preset/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ preset })
});
const data = await r.json().catch(() => ({}));
if (data.success) showToast(t('display.msg.color_changed'), 'success');
else showToast(t('display.msg.color_failed'), 'error');
} catch (e) {
console.error('Failed to set color preset:', e);
showToast(t('display.msg.color_failed'), 'error');
}
}
export async function onDisplayPictureModeChange(monitorId, codeRaw) {
const code = parseInt(codeRaw, 10);
if (Number.isNaN(code)) return;
try {
const r = await fetch(`/api/display/picture_mode/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ code })
});
const data = await r.json().catch(() => ({}));
if (data.success) showToast(t('display.msg.mode_changed'), 'success');
else showToast(t('display.msg.mode_failed'), 'error');
} catch (e) {
console.error('Failed to set picture mode:', e);
showToast(t('display.msg.mode_failed'), 'error');
}
}
function _onPowerButtonClick(event) {
const btn = event.target.closest('[data-action="toggle-power"]');
if (!btn) return;
const id = Number(btn.dataset.monitorId);
if (Number.isFinite(id)) toggleDisplayPower(id);
}
function _onDisplaySliderInput(event) {
const el = event.target.closest('input[data-display-slider]');
if (!el) return;
const id = Number(el.dataset.monitorId);
if (!Number.isFinite(id)) return;
if (el.dataset.displaySlider === 'brightness') {
onDisplayBrightnessInput(id, el.value);
} else if (el.dataset.displaySlider === 'contrast') {
onDisplayContrastInput(id, el.value);
}
}
function _onDisplaySliderChange(event) {
const el = event.target.closest('input[data-display-slider]');
if (!el) return;
const id = Number(el.dataset.monitorId);
if (!Number.isFinite(id)) return;
if (el.dataset.displaySlider === 'brightness') {
onDisplayBrightnessChange(id, el.value);
} else if (el.dataset.displaySlider === 'contrast') {
onDisplayContrastChange(id, el.value);
}
}
export async function toggleDisplayPower(monitorId) {
const btn = document.getElementById(`power-btn-${monitorId}`); const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on'); const isOn = btn && btn.classList.contains('on');
const newState = !isOn; const newState = !isOn;
@@ -142,13 +501,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
btn.classList.toggle('off', !newState); btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on'); btn.title = newState ? t('display.power_off') : t('display.power_on');
} }
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success'); showToast(t(newState ? 'display.msg.power_on' : 'display.msg.power_off'), 'success');
} else { } else {
showToast('Failed to change monitor power', 'error'); showToast(t('display.msg.power_failed'), 'error');
} }
} catch (e) { } catch (e) {
console.error('Failed to set display power:', e); console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error'); showToast(t('display.msg.power_failed'), 'error');
} }
} }
@@ -177,6 +536,8 @@ export async function loadHeaderLinks() {
a.href = link.url; a.href = link.url;
a.target = '_blank'; a.target = '_blank';
a.rel = 'noopener noreferrer'; a.rel = 'noopener noreferrer';
// Prevent leaking the WebUI URL (with ?token=) via Referer.
a.referrerPolicy = 'no-referrer';
a.className = 'header-link'; a.className = 'header-link';
a.title = link.label || link.url; a.title = link.label || link.url;
@@ -245,7 +606,7 @@ async function _loadLinksTableImpl() {
resolveMdiIcons(tbody); resolveMdiIcons(tbody);
} catch (error) { } catch (error) {
console.error('Error loading links:', error); console.error('Error loading links:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>'; tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('links.msg.load_failed'))}</td></tr>`;
} }
} }
@@ -348,9 +709,10 @@ export async function saveLink(event) {
description: document.getElementById('linkDescription').value || '' description: document.getElementById('linkDescription').value || ''
}; };
const encodedName = encodeURIComponent(linkName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/links/update/${linkName}` : `/api/links/update/${encodedName}` :
`/api/links/create/${linkName}`; `/api/links/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -384,7 +746,7 @@ export async function deleteLinkConfirm(linkName) {
} }
try { try {
const response = await fetch(`/api/links/delete/${linkName}`, { const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
+153 -47
View File
@@ -8,11 +8,13 @@ import {
ws, currentState, setCurrentState, currentDuration, setCurrentDuration, ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
currentPosition, setCurrentPosition, isUserAdjustingVolume, currentPosition, setCurrentPosition, isUserAdjustingVolume,
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState, lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
POSITION_INTERPOLATION_MS, seek, POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
getAuthHeaders, hasCredentials, getAuthHeaders, hasCredentials,
togglePlayPause, nextTrack, previousTrack,
} 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';
import { loadForegroundProcess } from './foreground.js';
import { IconSelect } from './icon-select.js'; import { IconSelect } from './icon-select.js';
// Tab management // Tab management
@@ -75,6 +77,7 @@ export function switchTab(tabName) {
if (tabName === 'display') { if (tabName === 'display') {
loadDisplayMonitors(); loadDisplayMonitors();
loadForegroundProcess();
} }
localStorage.setItem('activeTab', tabName); localStorage.setItem('activeTab', tabName);
@@ -205,20 +208,39 @@ export function renderAccentSwatches() {
const swatches = accentPresets.map(p => const swatches = accentPresets.map(p =>
`<div class="accent-swatch ${p.color === current ? 'active' : ''}" `<div class="accent-swatch ${p.color === current ? 'active' : ''}"
style="background: ${p.color}" style="background: ${p.color}"
onclick="selectAccentColor('${p.color}', '${p.hover}')" data-accent-color="${p.color}" data-accent-hover="${p.hover}"
title="${p.name}"></div>` title="${p.name}"></div>`
).join(''); ).join('');
const customRow = ` const customRow = `
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()"> <div class="accent-custom-row ${isCustom ? 'active' : ''}" data-accent-custom-row>
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span> <span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
<span class="accent-custom-label">${t('accent.custom')}</span> <span class="accent-custom-label">${t('accent.custom')}</span>
<input type="color" id="accentCustomInput" value="${current}" <input type="color" id="accentCustomInput" value="${current}">
onclick="event.stopPropagation()"
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
</div>`; </div>`;
dropdown.innerHTML = swatches + customRow; dropdown.innerHTML = swatches + customRow;
// Wire CSP-safe handlers (script-src 'self' blocks inline on* attributes).
dropdown.querySelectorAll('.accent-swatch[data-accent-color]').forEach(el => {
el.addEventListener('click', () => {
selectAccentColor(el.dataset.accentColor, el.dataset.accentHover);
});
});
const customRowEl = dropdown.querySelector('[data-accent-custom-row]');
const customInput = dropdown.querySelector('#accentCustomInput');
if (customRowEl && customInput) {
customRowEl.addEventListener('click', (e) => {
// The native color popup only opens from a user-initiated click on
// the <input>. Forward clicks on the row to the input — except when
// the input itself was the source (avoids re-entry).
if (e.target !== customInput) customInput.click();
});
customInput.addEventListener('click', (e) => e.stopPropagation());
customInput.addEventListener('change', () => {
selectAccentColor(customInput.value, lightenColor(customInput.value, 15));
});
}
} }
export function selectAccentColor(color, hover) { export function selectAccentColor(color, hover) {
@@ -360,11 +382,85 @@ function buildVisualizerGradient() {
function startVisualizerRender() { function startVisualizerRender() {
if (visualizerAnimFrame) return; if (visualizerAnimFrame) return;
// Don't even queue a frame while the tab is hidden — rAF still fires on
// hidden tabs (throttled but not paused) and would burn CPU + battery
// smoothing into bars no one can see. We resume on `visibilitychange`.
if (typeof document !== 'undefined' && document.hidden) return;
// Cache editorial spectrum bar refs once per start. // Cache editorial spectrum bar refs once per start.
cacheEditorialSpectrumBars(); cacheEditorialSpectrumBars();
renderVisualizerFrame(); renderVisualizerFrame();
} }
// ─── OS Media Session integration ─────────────────────────────
// Hooks the page into the system's media session so headset / lockscreen /
// Bluetooth play-pause-skip buttons drive the active track. Action handlers
// are set once and never re-registered; only the metadata + playback state
// flip when a track changes.
let _mediaSessionInitialised = false;
let _lastMediaSessionKey = '';
function syncMediaSession(status) {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
const session = navigator.mediaSession;
if (!_mediaSessionInitialised) {
const setHandler = (name, fn) => {
try { session.setActionHandler(name, fn); } catch { /* unsupported action */ }
};
setHandler('play', () => togglePlayPause());
setHandler('pause', () => togglePlayPause());
setHandler('nexttrack', () => nextTrack());
setHandler('previoustrack', () => previousTrack());
setHandler('seekto', (ev) => { if (ev && typeof ev.seekTime === 'number') seek(ev.seekTime); });
_mediaSessionInitialised = true;
}
// Track-identity key — re-build metadata only when title/artist/album change.
const artworkSrc = status && status.album_art_url ? '/api/media/artwork' : '';
const key = `${status.title || ''}|${status.artist || ''}|${status.album || ''}|${artworkSrc}`;
if (key !== _lastMediaSessionKey) {
_lastMediaSessionKey = key;
try {
session.metadata = new MediaMetadata({
title: status.title || '',
artist: status.artist || '',
album: status.album || '',
artwork: artworkSrc ? [{ src: artworkSrc, sizes: '512x512', type: 'image/*' }] : [],
});
} catch { /* MediaMetadata unsupported on very old browsers */ }
}
session.playbackState =
status.state === 'playing' ? 'playing'
: status.state === 'paused' ? 'paused'
: 'none';
if (typeof session.setPositionState === 'function'
&& status.duration && status.duration > 0
&& typeof status.position === 'number') {
try {
session.setPositionState({
duration: status.duration,
position: Math.min(status.position, status.duration),
playbackRate: 1.0,
});
} catch { /* invalid range — ignore */ }
}
}
// Pause / resume the visualizer with tab visibility. Idempotent: called once
// at module init below, no-op if no listener support.
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopVisualizerRender();
} else if (frequencyData) {
// Only restart if a payload is live (otherwise startVisualizerRender
// would queue a no-op rAF chain forever waiting for one).
startVisualizerRender();
}
});
}
export function stopVisualizerRender() { export function stopVisualizerRender() {
if (visualizerAnimFrame) { if (visualizerAnimFrame) {
cancelAnimationFrame(visualizerAnimFrame); cancelAnimationFrame(visualizerAnimFrame);
@@ -687,54 +783,49 @@ export async function onAudioDeviceChanged() {
let lastArtworkKey = null; let lastArtworkKey = null;
let currentArtworkBlobUrl = null; let currentArtworkBlobUrl = null;
let artworkFetchGen = 0;
let artworkAbort = null;
let lastPositionUpdate = 0; let lastPositionUpdate = 0;
let lastPositionValue = 0; let lastPositionValue = 0;
let interpolationInterval = null; let interpolationInterval = null;
export function setupProgressDrag(bar, fill) { export function setupProgressDrag(bar, fill) {
let dragging = false; // Listeners are attached on mousedown and removed on mouseup so the
// document doesn't carry per-progress-bar move handlers for the entire
// session (especially expensive on mobile).
function getPercent(clientX) { function getPercent(clientX) {
const rect = bar.getBoundingClientRect(); const rect = bar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
} }
function updatePreview(percent) { fill.style.width = (percent * 100) + '%'; }
function updatePreview(percent) { function pointerStart(getX, moveEvent, endEvent, getMoveX, getEndX) {
fill.style.width = (percent * 100) + '%';
}
function handleStart(clientX) {
if (currentDuration <= 0) return; if (currentDuration <= 0) return;
dragging = true;
bar.classList.add('dragging'); bar.classList.add('dragging');
updatePreview(getPercent(clientX)); updatePreview(getPercent(getX));
}
function handleMove(clientX) { function onMove(e) { updatePreview(getPercent(getMoveX(e))); }
if (!dragging) return; function onEnd(e) {
updatePreview(getPercent(clientX)); document.removeEventListener(moveEvent, onMove);
} document.removeEventListener(endEvent, onEnd);
bar.classList.remove('dragging');
function handleEnd(clientX) { const clientX = getEndX(e);
if (!dragging) return; if (clientX !== undefined) seek(getPercent(clientX) * currentDuration);
dragging = false;
bar.classList.remove('dragging');
const percent = getPercent(clientX);
seek(percent * currentDuration);
}
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
document.addEventListener('touchend', (e) => {
if (dragging) {
const touch = e.changedTouches[0];
handleEnd(touch.clientX);
} }
document.addEventListener(moveEvent, onMove);
document.addEventListener(endEvent, onEnd);
}
bar.addEventListener('mousedown', (e) => {
e.preventDefault();
pointerStart(e.clientX, 'mousemove', 'mouseup',
(ev) => ev.clientX, (ev) => ev.clientX);
}); });
bar.addEventListener('touchstart', (e) => {
pointerStart(e.touches[0].clientX, 'touchmove', 'touchend',
(ev) => ev.touches[0].clientX,
(ev) => ev.changedTouches?.[0]?.clientX);
}, { passive: true });
bar.addEventListener('click', (e) => { bar.addEventListener('click', (e) => {
if (currentDuration > 0) { if (currentDuration > 0) {
@@ -811,28 +902,35 @@ export function updateUI(status) {
lastArtworkKey = artworkKey; lastArtworkKey = artworkKey;
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' 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%3Cpath fill='%236a6a6a' opacity='0.35' 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";
// Cancel any in-flight artwork fetch and bump the generation so a
// late response from a previous track cannot overwrite the new one.
if (artworkAbort) {
try { artworkAbort.abort(); } catch { /* ignore */ }
}
const myGen = ++artworkFetchGen;
artworkAbort = new AbortController();
if (artworkSource) { if (artworkSource) {
// No cache-buster: when album_art_url is unchanged the
// browser can reuse the decoded bitmap. The artworkKey gate
// already skips fetches when the user hasn't switched tracks.
fetch('/api/media/artwork', { fetch('/api/media/artwork', {
headers: getAuthHeaders() headers: getAuthHeaders(),
signal: artworkAbort.signal,
}) })
.then(r => r.ok ? r.blob() : null) .then(r => r.ok ? r.blob() : null)
.then(blob => { .then(blob => {
if (!blob) return; if (!blob || myGen !== artworkFetchGen) return;
const oldBlobUrl = currentArtworkBlobUrl; const oldBlobUrl = currentArtworkBlobUrl;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url; currentArtworkBlobUrl = url;
swapArtworkSrc(dom.albumArt, url); swapArtworkSrc(dom.albumArt, url);
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url; if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url; if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
// Mirror to fullscreen bloom directly — drops the
// MutationObserver fan-out path.
syncFullscreenBloomArt(url); syncFullscreenBloomArt(url);
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000); if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
}) })
.catch(err => console.error('Artwork fetch failed:', err)); .catch(err => {
if (err && err.name === 'AbortError') return;
console.error('Artwork fetch failed:', err);
});
} else { } else {
if (currentArtworkBlobUrl) { if (currentArtworkBlobUrl) {
URL.revokeObjectURL(currentArtworkBlobUrl); URL.revokeObjectURL(currentArtworkBlobUrl);
@@ -858,6 +956,9 @@ export function updateUI(status) {
} }
if (!isUserAdjustingVolume) { if (!isUserAdjustingVolume) {
// Re-seed the throttling cache so a future call to setVolume() with
// the previously-sent value still propagates after an external change.
notifyRemoteVolume(status.volume);
dom.volumeSlider.value = status.volume; dom.volumeSlider.value = status.volume;
dom.volumeDisplay.textContent = `${status.volume}%`; dom.volumeDisplay.textContent = `${status.volume}%`;
dom.miniVolumeSlider.value = status.volume; dom.miniVolumeSlider.value = status.volume;
@@ -877,6 +978,11 @@ export function updateUI(status) {
updateMuteIcon(status.muted); updateMuteIcon(status.muted);
// Wire the OS Media Session so headset buttons, lockscreen controls, and
// Bluetooth remotes drive the active media (not the WebUI tab). Cheap and
// idempotent — re-running setActionHandler with the same fn is a no-op.
syncMediaSession(status);
const src = resolveMediaSource(status.source); const src = resolveMediaSource(status.source);
dom.source.textContent = src ? src.name : t('player.unknown_source'); dom.source.textContent = src ? src.name : t('player.unknown_source');
dom.sourceIcon.innerHTML = src?.icon || ''; dom.sourceIcon.innerHTML = src?.icon || '';
+31 -27
View File
@@ -80,6 +80,9 @@ export async function displayQuickAccess() {
card.href = link.url; card.href = link.url;
card.target = '_blank'; card.target = '_blank';
card.rel = 'noopener noreferrer'; card.rel = 'noopener noreferrer';
// Prevent the WebUI's URL (which may carry ?token=...) from
// leaking to third-party sites via Referer.
card.referrerPolicy = 'no-referrer';
if (link.icon) { if (link.icon) {
const iconEl = document.createElement('div'); const iconEl = document.createElement('div');
@@ -150,7 +153,7 @@ async function executeScript(scriptName, buttonElement) {
async function _doExecuteScript(scriptName, params) { async function _doExecuteScript(scriptName, params) {
try { try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, { const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ params }) body: JSON.stringify({ params })
@@ -393,7 +396,7 @@ async function _loadScriptsTableImpl() {
resolveMdiIcons(tbody); resolveMdiIcons(tbody);
} catch (error) { } catch (error) {
console.error('Error loading scripts:', error); console.error('Error loading scripts:', error);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>'; tbody.innerHTML = `<tr><td colspan="5" class="empty-state" style="color: var(--error);">${escapeHtml(t('scripts.msg.load_failed'))}</td></tr>`;
} }
} }
@@ -433,7 +436,7 @@ export async function showEditScriptDialog(scriptName) {
const script = scriptsList.find(s => s.name === scriptName); const script = scriptsList.find(s => s.name === scriptName);
if (!script) { if (!script) {
showToast('Script not found', 'error'); showToast(t('scripts.msg.not_found'), 'error');
return; return;
} }
@@ -470,7 +473,7 @@ export async function showEditScriptDialog(scriptName) {
dialog.showModal(); dialog.showModal();
} catch (error) { } catch (error) {
console.error('Error loading script for edit:', error); console.error('Error loading script for edit:', error);
showToast('Failed to load script details', 'error'); showToast(t('scripts.msg.load_failed'), 'error');
} }
} }
@@ -508,9 +511,10 @@ export async function saveScript(event) {
parameters: _collectParameterDefinitions(), parameters: _collectParameterDefinitions(),
}; };
const encodedName = encodeURIComponent(scriptName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` : `/api/scripts/update/${encodedName}` :
`/api/scripts/create/${scriptName}`; `/api/scripts/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -524,15 +528,15 @@ export async function saveScript(event) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
scriptFormDirty = false; scriptFormDirty = false;
closeScriptDialog(); closeScriptDialog();
} else { } else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error'); showToast(result.detail || t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Error saving script:', error); console.error('Error saving script:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error'); showToast(t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
} finally { } finally {
if (submitBtn) submitBtn.disabled = false; if (submitBtn) submitBtn.disabled = false;
} }
@@ -544,7 +548,7 @@ export async function deleteScriptConfirm(scriptName) {
} }
try { try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, { const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
@@ -552,13 +556,13 @@ export async function deleteScriptConfirm(scriptName) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast('Script deleted successfully', 'success'); showToast(t('scripts.msg.deleted'), 'success');
} else { } else {
showToast(result.detail || 'Failed to delete script', 'error'); showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Error deleting script:', error); console.error('Error deleting script:', error);
showToast('Error deleting script', 'error'); showToast(t('scripts.msg.delete_failed'), 'error');
} }
} }
@@ -581,23 +585,23 @@ function showExecutionResult(name, result, type = 'script') {
const outputPre = document.getElementById('executionOutput'); const outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError'); const errorPre = document.getElementById('executionError');
title.textContent = `Execution Result: ${name}`; title.textContent = `${t('execution.result')}: ${name}`;
const success = result.success && result.exit_code === 0; const success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error'; const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed'; const statusText = t(success ? 'execution.success' : 'execution.failed');
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item ${statusClass}"> <div class="status-item ${statusClass}">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value>${statusText}</value> <value>${escapeHtml(statusText)}</value>
</div> </div>
<div class="status-item"> <div class="status-item">
<label>Exit Code</label> <label>${escapeHtml(t('execution.exit_code'))}</label>
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value> <value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div> </div>
<div class="status-item"> <div class="status-item">
<label>Duration</label> <label>${escapeHtml(t('execution.duration'))}</label>
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value> <value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div> </div>
`; `;
@@ -606,7 +610,7 @@ function showExecutionResult(name, result, type = 'script') {
if (result.stdout && result.stdout.trim()) { if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout; outputPre.textContent = result.stdout;
} else { } else {
outputPre.textContent = '(no output)'; outputPre.textContent = t('execution.no_output');
outputPre.style.fontStyle = 'italic'; outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)'; outputPre.style.color = 'var(--text-secondary)';
} }
@@ -642,11 +646,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
const title = document.getElementById('executionDialogTitle'); const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus'); const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${scriptName}`; title.textContent = `${t('execution.executing')}: ${scriptName}`;
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item"> <div class="status-item">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value><span class="loading-spinner"></span> Running...</value> <value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
</div> </div>
`; `;
document.getElementById('outputSection').style.display = 'none'; document.getElementById('outputSection').style.display = 'none';
@@ -813,11 +817,11 @@ export async function executeCallbackDebug(callbackName) {
const title = document.getElementById('executionDialogTitle'); const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus'); const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${callbackName}`; title.textContent = `${t('execution.executing')}: ${callbackName}`;
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item"> <div class="status-item">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value><span class="loading-spinner"></span> Running...</value> <value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
</div> </div>
`; `;
document.getElementById('outputSection').style.display = 'none'; document.getElementById('outputSection').style.display = 'none';
@@ -826,7 +830,7 @@ export async function executeCallbackDebug(callbackName) {
dialog.showModal(); dialog.showModal();
try { try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, { const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() } headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
}); });
+93 -17
View File
@@ -3,7 +3,7 @@
// ============================================================ // ============================================================
import { import {
dom, t, showToast, setWs, dom, t, setWs, getWs,
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, showUpdateBanner, authRequired, showUpdateBanner,
@@ -12,10 +12,29 @@ import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js'; import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
import { loadCallbacksTable } from './callbacks.js'; import { loadCallbacksTable } from './callbacks.js';
import { loadHeaderLinks, loadLinksTable } from './links.js'; import { loadHeaderLinks, loadLinksTable } from './links.js';
import { updateForegroundUI } from './foreground.js';
let reconnectTimeout = null; let reconnectTimeout = null;
let pingInterval = null;
let wsReconnectAttempts = 0; 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 = '') { export function showAuthForm(errorMessage = '') {
const overlay = document.getElementById('auth-overlay'); const overlay = document.getElementById('auth-overlay');
@@ -47,26 +66,45 @@ export function authenticate() {
export function clearToken() { export function clearToken() {
localStorage.removeItem('media_server_token'); localStorage.removeItem('media_server_token');
// Access ws via import const current = getWs();
import('./core.js').then(core => { if (current) {
if (core.ws) { try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
core.ws.close(); }
}
});
showAuthForm(t('auth.cleared')); showAuthForm(t('auth.cleared'));
} }
export function connectWebSocket(token) { export function connectWebSocket(token) {
if (pingInterval) { // Always cancel a pending reconnect first — otherwise a user-triggered
clearInterval(pingInterval); // reconnect can race a scheduled one and create two live sockets.
pingInterval = null; 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 protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsBase = `${protocol}//${window.location.host}/api/media/ws`; const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl); // Prefer Sec-WebSocket-Protocol-based auth so the token never appears in
// the URL (which would otherwise land in browser history, server access
// logs, and Referer headers). Keep the ?token=... fallback for clients
// that pre-date this change and don't speak the subprotocol.
let newWs;
if (token) {
try {
newWs = new WebSocket(wsBase, [`media-server.token.${token}`]);
} catch (e) {
console.warn('Subprotocol WS handshake failed, falling back to ?token=', e);
newWs = new WebSocket(`${wsBase}?token=${encodeURIComponent(token)}`);
}
} else {
newWs = new WebSocket(wsBase);
}
activeSocket = newWs;
setWs(newWs); setWs(newWs);
newWs.onopen = () => { newWs.onopen = () => {
@@ -84,10 +122,18 @@ export function connectWebSocket(token) {
}; };
newWs.onmessage = (event) => { newWs.onmessage = (event) => {
const msg = JSON.parse(event.data); 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') { if (msg.type === 'status' || msg.type === 'status_update') {
updateUI(msg.data); updateUI(msg.data);
} else if (msg.type === 'foreground' || msg.type === 'foreground_update') {
updateForegroundUI(msg.data);
} else if (msg.type === 'scripts_changed') { } else if (msg.type === 'scripts_changed') {
console.log('Scripts changed, reloading...'); console.log('Scripts changed, reloading...');
loadScripts(); loadScripts();
@@ -116,6 +162,13 @@ export function connectWebSocket(token) {
updateConnectionStatus(false); updateConnectionStatus(false);
stopPositionInterpolation(); 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) { if (event.code === 4001) {
localStorage.removeItem('media_server_token'); localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid')); showAuthForm(t('auth.invalid'));
@@ -133,7 +186,9 @@ export function connectWebSocket(token) {
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false); showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
} }
clearReconnect();
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
const savedToken = localStorage.getItem('media_server_token'); const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) { if (savedToken || !authRequired) {
connectWebSocket(savedToken || ''); connectWebSocket(savedToken || '');
@@ -145,9 +200,10 @@ export function connectWebSocket(token) {
} }
}; };
pingInterval = setInterval(() => { clearPing();
if (newWs && newWs.readyState === WebSocket.OPEN) { activePingInterval = setInterval(() => {
newWs.send(JSON.stringify({ type: 'ping' })); if (newWs.readyState === WebSocket.OPEN) {
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
} }
}, WS_PING_INTERVAL_MS); }, WS_PING_INTERVAL_MS);
} }
@@ -182,3 +238,23 @@ export function manualReconnect() {
connectWebSocket(savedToken || ''); 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();
});
+50 -1
View File
@@ -161,6 +161,31 @@
"display.power_on": "Turn on", "display.power_on": "Turn on",
"display.power_off": "Turn off", "display.power_off": "Turn off",
"display.primary": "Primary", "display.primary": "Primary",
"display.brightness": "Brightness",
"display.contrast": "Contrast",
"display.tuning": "Picture tuning",
"display.input_source": "Input",
"display.color_preset": "Color temp",
"display.picture_mode": "Picture mode",
"display.msg.contrast_failed": "Failed to set contrast",
"display.msg.input_changed": "Input source switched",
"display.msg.input_failed": "Failed to switch input source",
"display.msg.color_changed": "Color preset applied",
"display.msg.color_failed": "Failed to apply color preset",
"display.msg.mode_changed": "Picture mode applied",
"display.msg.mode_failed": "Failed to apply picture mode",
"display.msg.power_on": "Monitor turned on",
"display.msg.power_off": "Monitor turned off",
"display.msg.power_failed": "Failed to change monitor power",
"execution.result": "Execution Result",
"execution.executing": "Executing",
"execution.status": "Status",
"execution.exit_code": "Exit Code",
"execution.duration": "Duration",
"execution.success": "Success",
"execution.failed": "Failed",
"execution.running": "Running...",
"execution.no_output": "(no output)",
"browser.title": "Media Browser", "browser.title": "Media Browser",
"browser.home": "Home", "browser.home": "Home",
"browser.manage_folders": "Manage Folders", "browser.manage_folders": "Manage Folders",
@@ -267,5 +292,29 @@
"about.source_code": "Source Code", "about.source_code": "Source Code",
"dialog.close": "Close", "dialog.close": "Close",
"update.available": "Update available: v{version}", "update.available": "Update available: v{version}",
"update.view_release": "View Release" "update.view_release": "View Release",
"tab.foreground": "Foreground",
"foreground.kicker": "Foreground",
"foreground.loading": "Waiting for foreground signal…",
"foreground.no_process": "No foreground process",
"foreground.unavailable": "Foreground tracking unavailable on this platform",
"foreground.process": "Process",
"foreground.window_title": "Window title",
"foreground.executable": "Executable",
"foreground.pid": "PID",
"foreground.monitor": "Monitor {n}",
"foreground.started": "Started",
"foreground.geometry": "Geometry",
"foreground.platform": "Platform",
"foreground.fullscreen": "Fullscreen",
"foreground.minimized": "Minimized",
"foreground.windowed": "Windowed",
"foreground.browser": "Browser",
"foreground.page_title": "Page title",
"foreground.url": "URL",
"foreground.badge.title": "View foreground process",
"foreground.ago.seconds": "{n}s ago",
"foreground.ago.minutes": "{n}m ago",
"foreground.ago.hours": "{n}h {m}m ago",
"foreground.ago.days": "{n}d ago"
} }
+50 -1
View File
@@ -161,6 +161,31 @@
"display.power_on": "Включить", "display.power_on": "Включить",
"display.power_off": "Выключить", "display.power_off": "Выключить",
"display.primary": "Основной", "display.primary": "Основной",
"display.brightness": "Яркость",
"display.contrast": "Контраст",
"display.tuning": "Настройка изображения",
"display.input_source": "Вход",
"display.color_preset": "Цветовая температура",
"display.picture_mode": "Режим изображения",
"display.msg.contrast_failed": "Не удалось установить контраст",
"display.msg.input_changed": "Источник входа переключён",
"display.msg.input_failed": "Не удалось переключить источник",
"display.msg.color_changed": "Цветовая температура применена",
"display.msg.color_failed": "Не удалось применить цветовую температуру",
"display.msg.mode_changed": "Режим изображения применён",
"display.msg.mode_failed": "Не удалось применить режим изображения",
"display.msg.power_on": "Монитор включён",
"display.msg.power_off": "Монитор выключен",
"display.msg.power_failed": "Не удалось переключить питание монитора",
"execution.result": "Результат выполнения",
"execution.executing": "Выполняется",
"execution.status": "Статус",
"execution.exit_code": "Код выхода",
"execution.duration": "Длительность",
"execution.success": "Успешно",
"execution.failed": "Ошибка",
"execution.running": "Выполняется...",
"execution.no_output": "(нет вывода)",
"browser.title": "Медиа Браузер", "browser.title": "Медиа Браузер",
"browser.home": "Главная", "browser.home": "Главная",
"browser.manage_folders": "Управление папками", "browser.manage_folders": "Управление папками",
@@ -267,5 +292,29 @@
"about.source_code": "Исходный код", "about.source_code": "Исходный код",
"dialog.close": "Закрыть", "dialog.close": "Закрыть",
"update.available": "Доступно обновление: v{version}", "update.available": "Доступно обновление: v{version}",
"update.view_release": "Перейти к релизу" "update.view_release": "Перейти к релизу",
"tab.foreground": "Активное окно",
"foreground.kicker": "Активное окно",
"foreground.loading": "Ожидание сигнала об активном окне…",
"foreground.no_process": "Активное окно не определено",
"foreground.unavailable": "Отслеживание активного окна недоступно",
"foreground.process": "Процесс",
"foreground.window_title": "Заголовок окна",
"foreground.executable": "Путь к программе",
"foreground.pid": "PID",
"foreground.monitor": "Монитор {n}",
"foreground.started": "Запущено",
"foreground.geometry": "Геометрия",
"foreground.platform": "Платформа",
"foreground.fullscreen": "Полноэкранный",
"foreground.minimized": "Свёрнут",
"foreground.windowed": "Оконный",
"foreground.browser": "Браузер",
"foreground.page_title": "Заголовок страницы",
"foreground.url": "URL",
"foreground.badge.title": "Открыть активное окно",
"foreground.ago.seconds": "{n} с назад",
"foreground.ago.minutes": "{n} мин назад",
"foreground.ago.hours": "{n} ч {m} мин назад",
"foreground.ago.days": "{n} дн назад"
} }
+4 -2
View File
@@ -1,12 +1,14 @@
{ {
"id": "/",
"name": "Media Server", "name": "Media Server",
"short_name": "Media", "short_name": "Media",
"description": "Remote media player control and file browser", "description": "Remote media player control and file browser",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#121212", "background_color": "#0E0D0B",
"theme_color": "#121212", "theme_color": "#0E0D0B",
"icons": [ "icons": [
{ {
"src": "/static/icons/icon.svg", "src": "/static/icons/icon.svg",
+4 -6
View File
@@ -1,6 +1,8 @@
// Minimal service worker for PWA installability. // Minimal service worker for PWA installability only.
// This app requires a live WebSocket connection, so offline caching is not useful. // This app requires a live WebSocket connection, so offline caching is not useful.
// All fetch requests are passed through to the network. // We intentionally do NOT register a `fetch` handler — a pass-through handler
// forces every navigation through the SW for no benefit and breaks the
// browser's normal HTTP cache + error semantics.
self.addEventListener('install', () => { self.addEventListener('install', () => {
self.skipWaiting(); self.skipWaiting();
@@ -9,7 +11,3 @@ self.addEventListener('install', () => {
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim()); event.waitUntil(self.clients.claim());
}); });
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
+121 -40
View File
@@ -3,6 +3,7 @@
import ctypes import ctypes
import io import io
import logging import logging
import sys
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@@ -30,62 +31,136 @@ _IDYES = 6
def _confirm(title: str, message: str) -> bool: def _confirm(title: str, message: str) -> bool:
"""Show a Yes/No dialog using native Windows MessageBox.""" """Show a Yes/No dialog before a destructive tray action.
result = ctypes.windll.user32.MessageBoxW(
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND Uses the native Windows MessageBox on win32; on Linux/macOS we don't
) have a no-dependency GUI dialog available (Tk pulls in tkinter, gtk
return result == _IDYES pulls in PyGObject), so we log + auto-confirm the tray menu items
themselves require a deliberate click already.
"""
if sys.platform == "win32":
result = ctypes.windll.user32.MessageBoxW(
0,
message,
title,
_MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND,
)
return result == _IDYES
logger.info("Tray confirm (auto-yes on non-Windows): %s%s", title, message)
return True
def _create_icon_image(size: int = 64) -> Image.Image: # Frame size we ask the multi-resolution ICO for. Most Windows tray surfaces
"""Create a tray icon: green circle with white play triangle.""" # show 16x16 in the notification area and 32x32 in jump lists / Alt+Tab; 64
# gives pystray enough headroom for both without forcing it to upscale.
_TRAY_ICON_SIZE = 64
# Palette mirrors media_server/static/icons/icon.svg ("Beacon" design).
_BG_DARK = (11, 61, 59, 255) # #0B3D3B
_BG_LIGHT = (26, 107, 94, 255) # #1A6B5E
_FG_PARCHMENT = (245, 241, 232, 255) # #F5F1E8
def _create_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image:
"""Procedural fallback when no icon file is available.
Matches the "Beacon" palette (deep teal squircle + warm parchment play
triangle) so a missing icon.ico does not regress us back to the old
Spotify-green circle.
"""
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# Green circle background # Squircle background. Vertical gradient approximates the diagonal one
padding = 2 # in the real SVG well enough for a 64px fallback.
draw.ellipse( radius = int(size * 0.225)
[padding, padding, size - padding, size - padding], for y in range(size):
fill=(29, 185, 84, 255), t = y / max(1, size - 1)
color = tuple(
round(_BG_DARK[i] + (_BG_LIGHT[i] - _BG_DARK[i]) * t) for i in range(3)
) + (255,)
draw.line([(0, y), (size - 1, y)], fill=color)
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
bg = img.copy()
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
img.paste(bg, (0, 0), mask=mask)
# Play triangle, positioned to match icon.svg's geometry.
draw = ImageDraw.Draw(img)
draw.polygon(
[
(size * 0.345, size * 0.215),
(size * 0.345, size * 0.785),
(size * 0.755, size * 0.500),
],
fill=_FG_PARCHMENT,
) )
# White play triangle
cx, cy = size // 2, size // 2
r = size * 0.28
triangle = [
(cx - r * 0.6, cy - r),
(cx - r * 0.6, cy + r),
(cx + r * 0.9, cy),
]
draw.polygon(triangle, fill=(255, 255, 255, 255))
return img return img
def _load_icon_image() -> Image.Image: def _select_frame(image: Image.Image, target: int) -> Image.Image:
"""Load the ICO/SVG app icon, falling back to a generated image.""" """Pick the best frame from a multi-resolution ICO.
Pillow's ICO loader exposes the embedded sizes via ``image.info['sizes']``.
We pick the smallest frame at least as large as the target (so we never
upscale) and resize down to ``target x target`` with LANCZOS.
"""
sizes = sorted(image.info.get("sizes", []) or [], key=lambda wh: wh[0])
chosen = next((wh for wh in sizes if wh[0] >= target), sizes[-1] if sizes else None)
if chosen is not None:
image.size = chosen
frame = image.copy().convert("RGBA")
if frame.size != (target, target):
frame = frame.resize((target, target), Image.LANCZOS)
return frame
def _load_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image:
"""Load the app icon for the tray.
Order:
1. ``icon.ico`` the multi-resolution Windows icon ships with every
build; pick the frame closest to ``size`` and downscale if needed.
2. ``icon.svg`` via resvg-py (preferred) or cairosvg (legacy).
3. Procedural ``_create_icon_image`` fallback.
"""
icons_dir = Path(__file__).parent / "static" / "icons" icons_dir = Path(__file__).parent / "static" / "icons"
# Try .ico first (best for Windows tray)
ico_path = icons_dir / "icon.ico" ico_path = icons_dir / "icon.ico"
if ico_path.exists(): if ico_path.exists():
try: try:
return Image.open(ico_path) with Image.open(ico_path) as ico:
return _select_frame(ico, size)
except Exception as exc:
logger.warning("Failed to load tray icon from %s: %s", ico_path, exc)
svg_path = icons_dir / "icon.svg"
if svg_path.exists():
try:
import resvg_py
png_data = resvg_py.svg_to_bytes(
svg_string=svg_path.read_text(encoding="utf-8"),
width=size,
height=size,
)
return Image.open(io.BytesIO(bytes(png_data))).convert("RGBA")
except ImportError:
pass
except Exception as exc:
logger.warning("resvg rasterization of %s failed: %s", svg_path, exc)
try:
import cairosvg
png_data = cairosvg.svg2png(url=str(svg_path), output_width=size, output_height=size)
return Image.open(io.BytesIO(png_data)).convert("RGBA")
except Exception: except Exception:
pass pass
# Try SVG via cairosvg return _create_icon_image(size)
try:
import cairosvg
svg_path = icons_dir / "icon.svg"
if svg_path.exists():
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
return Image.open(io.BytesIO(png_data))
except Exception:
pass
return _create_icon_image()
class TrayManager: class TrayManager:
@@ -101,6 +176,9 @@ class TrayManager:
self._port = port self._port = port
self._on_exit = on_exit self._on_exit = on_exit
# Initialize so the property and any cross-thread reader cannot ever
# observe an uninitialized attribute. Set before _on_exit() fires.
self._restart_requested = False
menu = pystray.Menu( menu = pystray.Menu(
pystray.MenuItem("Show UI", self._show_ui, default=True), pystray.MenuItem("Show UI", self._show_ui, default=True),
@@ -123,13 +201,16 @@ class TrayManager:
if not _confirm("Media Server", "Restart the server?"): if not _confirm("Media Server", "Restart the server?"):
return return
logger.info("Restart requested from tray") logger.info("Restart requested from tray")
# Set the flag BEFORE signalling exit so the main thread observes it
# when it wakes from server_thread.join() — order matters across the
# tray/uvicorn handoff.
self._restart_requested = True self._restart_requested = True
self._on_exit() self._on_exit()
self._icon.stop() self._icon.stop()
@property @property
def restart_requested(self) -> bool: def restart_requested(self) -> bool:
return getattr(self, "_restart_requested", False) return self._restart_requested
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None: def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Shut down the server?"): if not _confirm("Media Server", "Shut down the server?"):
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.2", "version": "0.4.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.2", "version": "0.4.0",
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.4" "esbuild": "^0.27.4"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.2", "version": "0.4.0",
"private": true, "private": true,
"description": "Frontend build tooling for media server WebUI", "description": "Frontend build tooling for media server WebUI",
"scripts": { "scripts": {
+27 -7
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "media-server" name = "media-server"
version = "0.2.2" version = "0.4.0"
description = "REST API server for controlling system-wide media playback" description = "REST API server for controlling system-wide media playback"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
@@ -34,24 +34,44 @@ dependencies = [
"pillow>=10.0.0", "pillow>=10.0.0",
"soundcard>=0.4.0", "soundcard>=0.4.0",
"numpy>=1.24.0,<2.0", "numpy>=1.24.0,<2.0",
# screen-brightness-control + monitorcontrol are cross-platform
# (Windows / Linux i2c-dev). Kept in base deps so a plain
# `pip install media-server` yields a working /api/display on any
# platform that has the underlying hardware support.
"screen-brightness-control>=0.20.0",
"monitorcontrol>=3.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
windows = [ windows = [
"winsdk>=1.0.0b10", "winsdk>=1.0.0b10",
"pywin32>=306", "pywin32>=306; sys_platform == 'win32'",
"comtypes>=1.2.0", "comtypes>=1.2.0; sys_platform == 'win32'",
"pycaw>=20230407", "pycaw>=20230407; sys_platform == 'win32'",
"screen-brightness-control>=0.20.0", "wmi>=1.5.1; sys_platform == 'win32'",
"wmi>=1.5.1",
"monitorcontrol>=3.0.0",
"pystray>=0.19.0", "pystray>=0.19.0",
] ]
linux = [
# MPRIS / D-Bus media control. Requires libdbus-1-dev, libglib2.0-dev,
# and pkg-config installed on the host before pip install.
"dbus-python>=1.3.2; sys_platform == 'linux'",
"PyGObject>=3.46; sys_platform == 'linux'",
# X11 foreground-window probe (Wayland sessions degrade gracefully).
"python-xlib>=0.33; sys_platform == 'linux'",
]
macos = [
# PyObjC for the foreground-window + active-app probes.
"pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
"pyobjc-framework-Quartz>=10.0; sys_platform == 'darwin'",
]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"pytest-asyncio>=0.21", "pytest-asyncio>=0.21",
"httpx>=0.24", "httpx>=0.24",
"ruff>=0.4.0", "ruff>=0.4.0",
# SVG -> PNG rasterizer used by scripts/generate-icon.py to (re)build
# media_server/static/icons/icon.ico from icon.svg. Build-time only.
"resvg-py>=0.3.2",
] ]
[project.urls] [project.urls]
+113
View File
@@ -0,0 +1,113 @@
"""Generate the Media Server application icon.
The SVG in ``media_server/static/icons/icon.svg`` is the single source of
truth. This script rasterizes it at every Windows ICO size via ``resvg-py``
(Rust-backed, identical math to Firefox's SVG renderer) and packs them all
into a multi-resolution ``icon.ico``.
This replaces the original 16x16-only ICO that Windows was upscaling into
mush for the installer chrome, Start Menu, desktop shortcuts, and Alt+Tab.
Usage:
python scripts/generate-icon.py
Dependencies:
pip install resvg-py Pillow
"""
from __future__ import annotations
import io
from pathlib import Path
import resvg_py
from PIL import Image
# Sizes packed into the ICO. Windows picks the closest match per surface;
# more sizes = sharper rendering everywhere (taskbar, installer header,
# Alt+Tab, jump lists, Start tile, desktop, file explorer details/tiles).
ICO_SIZES: tuple[int, ...] = (16, 20, 24, 32, 40, 48, 64, 96, 128, 256)
# The SVG source. Squircle + diagonal teal gradient + warm parchment play
# triangle with a drop shadow + ghosted echo-chevrons hinting at broadcast.
SVG_SOURCE = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0B3D3B"/>
<stop offset="100%" stop-color="#1A6B5E"/>
</linearGradient>
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3"/>
<feOffset dx="3" dy="5"/>
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
</defs>
<g clip-path="url(#clip)">
<rect width="256" height="256" fill="url(#bg)"/>
<rect width="256" height="256" fill="url(#sheen)"/>
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
<polyline points="66,72 105,128 66,184"/>
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
</g>
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
fill="#F5F1E8"
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
filter="url(#triShadow)"/>
</g>
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
</svg>
"""
def _render_png(size: int) -> Image.Image:
"""Rasterize the SVG to a PNG at ``size x size`` via resvg."""
data = resvg_py.svg_to_bytes(
svg_string=SVG_SOURCE,
width=size,
height=size,
shape_rendering="geometric_precision",
)
return Image.open(io.BytesIO(bytes(data))).convert("RGBA")
def main() -> None:
root = Path(__file__).resolve().parent.parent
out_dir = root / "media_server" / "static" / "icons"
out_dir.mkdir(parents=True, exist_ok=True)
# SVG — canonical source, also used as the Web UI favicon.
svg_path = out_dir / "icon.svg"
svg_path.write_text(SVG_SOURCE, encoding="utf-8")
print(f"wrote {svg_path}")
# Rasterize every ICO size via resvg.
frames = [_render_png(size) for size in ICO_SIZES]
# Pack into a multi-resolution ICO. The "primary" image must be the
# largest; the rest go via append_images. Pillow's ICO writer then
# serializes one frame per size.
primary = frames[-1]
ico_path = out_dir / "icon.ico"
primary.save(
ico_path,
format="ICO",
sizes=[(s, s) for s in ICO_SIZES],
append_images=frames[:-1],
)
print(f"wrote {ico_path} ({ico_path.stat().st_size:,} bytes, sizes={list(ICO_SIZES)})")
# Largest PNG for documentation / non-Windows surfaces.
png_path = out_dir / "icon-256.png"
primary.save(png_path, format="PNG", optimize=True)
print(f"wrote {png_path}")
if __name__ == "__main__":
main()
+94 -26
View File
@@ -1,35 +1,103 @@
# Restart the Media Server # Restart the Media Server.
# Stop any running instance #
$procs = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue # Robust against the two ways the server gets started:
foreach ($p in $procs) { # - Installer build: %LOCALAPPDATA%\Media Server\media-server.bat
Write-Host "Stopping server (PID $($p.Id))..." # (runs as python.exe -m media_server.main)
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue # - Dev editable install: media-server console script on PATH
} # (runs as media-server.exe)
if ($procs) { Start-Sleep -Seconds 2 } #
# The old version of this script only killed processes named 'media-server',
# which silently missed the installer-bundled process (named 'python').
# This version kills whatever currently owns the listen port, so it doesn't
# matter how the previous instance was launched.
# Merge registry PATH with current PATH so newly-installed tools are visible param(
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User') [ValidateSet('auto', 'dev', 'installer')]
if ($regUser) { [string]$Mode = 'auto',
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') } [int]$Port = 8765
foreach ($dir in ($regUser -split ';')) { )
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir" $InstallerLauncher = Join-Path $env:LOCALAPPDATA 'Media Server\media-server.bat'
} $InstallerDir = Join-Path $env:LOCALAPPDATA 'Media Server'
# --- Resolve launch mode -----------------------------------------------------
if ($Mode -eq 'auto') {
if (Test-Path $InstallerLauncher) {
$Mode = 'installer'
} else {
$Mode = 'dev'
} }
} }
# Start server detached # --- Stop whatever is listening on the port ---------------------------------
Write-Host "Starting server..." $listenerPids = @()
Start-Process -FilePath 'media-server' ` try {
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' ` $conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
-WindowStyle Hidden if ($conns) {
$listenerPids = $conns | Select-Object -ExpandProperty OwningProcess -Unique
}
} catch {
# Get-NetTCPConnection unavailable (rare); fall back to netstat parsing
$listenerPids = & netstat -ano | Select-String ":$Port\s+.*LISTENING" | ForEach-Object {
($_ -split '\s+')[-1]
} | Sort-Object -Unique
}
foreach ($targetPid in $listenerPids) {
$proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Stopping listener PID $($proc.Id) ($($proc.ProcessName))..."
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
}
# Also kill any orphan media-server.exe instances that didn't bind the port.
$orphans = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
foreach ($p in $orphans) {
Write-Host "Stopping orphan media-server PID $($p.Id)..."
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
if ($listenerPids -or $orphans) {
# Allow the OS to release the listen socket from TIME_WAIT.
Start-Sleep -Seconds 3
}
# --- Start the chosen flavour ------------------------------------------------
if ($Mode -eq 'installer') {
if (-not (Test-Path $InstallerLauncher)) {
Write-Error "Installer launcher not found: $InstallerLauncher"
exit 1
}
Write-Host "Starting installer build: $InstallerLauncher"
Start-Process -FilePath $InstallerLauncher `
-WorkingDirectory $InstallerDir `
-WindowStyle Hidden
} else {
# Merge registry PATH so newly-installed dev tools are visible.
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
foreach ($dir in ($regUser -split ';')) {
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir"
}
}
}
Write-Host "Starting dev install (PATH media-server)..."
Start-Process -FilePath 'media-server' `
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
-WindowStyle Hidden
}
Start-Sleep -Seconds 3 Start-Sleep -Seconds 3
# Verify it's running # --- Verify it's listening ---------------------------------------------------
$check = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue $verify = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
if ($check) { if ($verify) {
Write-Host "Server started (PID $($check[0].Id))" $vpid = $verify[0].OwningProcess
$vproc = Get-Process -Id $vpid -ErrorAction SilentlyContinue
Write-Host "Server listening on port $Port (PID $vpid, $($vproc.ProcessName))"
} else { } else {
Write-Host "WARNING: Server does not appear to be running!" Write-Warning "Server is not listening on port $Port yet - check logs."
} }
+58
View File
@@ -0,0 +1,58 @@
"""Tests for token scope hierarchy + back-compat with legacy bare-string tokens."""
from __future__ import annotations
import pytest
from media_server.config import Settings, TokenSpec
def test_bare_string_token_promotes_to_admin_scope():
"""Legacy `label: <token>` form must still work and grant admin."""
s = Settings(api_tokens={"legacy": "deadbeef-deadbeef-deadbeef-deadbeef"})
spec = s.api_tokens["legacy"]
assert isinstance(spec, TokenSpec)
assert spec.token == "deadbeef-deadbeef-deadbeef-deadbeef"
assert spec.scopes == ["admin"]
assert spec.grants("admin")
assert spec.grants("control")
assert spec.grants("read")
def test_dict_token_with_explicit_scopes():
s = Settings(api_tokens={
"ha": {"token": "aaaaaaaaaaaaaaaa", "scopes": ["read", "control"]},
})
spec = s.api_tokens["ha"]
assert spec.grants("control")
assert spec.grants("read")
assert not spec.grants("admin")
def test_read_only_scope_grants_only_read():
spec = TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["read"])
assert spec.grants("read")
assert not spec.grants("control")
assert not spec.grants("admin")
def test_admin_scope_implies_control_and_read():
spec = TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["admin"])
assert spec.grants("read")
assert spec.grants("control")
assert spec.grants("admin")
def test_unknown_scope_rejected():
with pytest.raises(ValueError, match="unknown scopes"):
TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["root"])
def test_empty_scopes_rejected():
with pytest.raises(ValueError, match="at least one"):
TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=[])
def test_short_token_rejected():
with pytest.raises(ValueError):
TokenSpec(token="short", scopes=["read"])
+84
View File
@@ -0,0 +1,84 @@
"""Path traversal defence for BrowserService.validate_path.
The browser endpoint is the single most security-critical filesystem entry
point in the app: it serves file contents and folder listings to the WebUI.
A bypass here = arbitrary read of any file the server process can see.
The current implementation signals rejection by *raising* (ValueError for
traversal/NUL/unknown folder, FileNotFoundError for non-existent absolute
paths). Either rejection mode is acceptable these tests assert that the
adversarial input never returns a path *inside* the configured base.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from media_server.services.browser_service import BrowserService
@pytest.fixture
def tmp_media_folder():
"""A real temp dir registered as a media folder for the test duration."""
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp).resolve()
(base / "ok.mp3").write_bytes(b"id3")
(base / "sub").mkdir()
(base / "sub" / "nested.mp3").write_bytes(b"id3")
from media_server.config import MediaFolderConfig
folders = {"test": MediaFolderConfig(path=str(base), label="Test", enabled=True)}
with patch("media_server.services.browser_service.settings.media_folders", folders):
yield base
def _is_rejected(folder_id: str, rel: str) -> bool:
"""Helper: True iff validate_path either raises or returns None."""
try:
result = BrowserService.validate_path(folder_id, rel)
except (ValueError, FileNotFoundError, OSError):
return True
return result is None
def test_validate_path_accepts_a_real_file(tmp_media_folder: Path):
p = BrowserService.validate_path("test", "ok.mp3")
assert p is not None
assert p.is_file()
# Defence-in-depth: resolved path must live inside the base.
assert tmp_media_folder in p.resolve().parents or p.resolve().parent == tmp_media_folder
def test_validate_path_accepts_nested(tmp_media_folder: Path):
p = BrowserService.validate_path("test", "sub/nested.mp3")
assert p is not None
def test_unknown_folder_rejected(tmp_media_folder: Path):
assert _is_rejected("ghost", "ok.mp3")
def test_dotdot_traversal_rejected(tmp_media_folder: Path):
assert _is_rejected("test", "../etc/passwd")
assert _is_rejected("test", "..\\..\\Windows\\System32")
assert _is_rejected("test", "sub/../../etc/passwd")
def test_absolute_path_rejected(tmp_media_folder: Path):
assert _is_rejected("test", "/etc/passwd")
assert _is_rejected("test", "C:\\Windows\\System32")
assert _is_rejected("test", "C:/Windows")
def test_unc_path_rejected(tmp_media_folder: Path):
assert _is_rejected("test", "\\\\server\\share")
assert _is_rejected("test", "//server/share")
def test_null_byte_rejected(tmp_media_folder: Path):
assert _is_rejected("test", "ok.mp3\x00.png")
assert _is_rejected("test", "sub\x00/nested.mp3")
+46
View File
@@ -0,0 +1,46 @@
"""Atomic config writes + POSIX permission hardening."""
from __future__ import annotations
import os
import stat
import sys
import tempfile
from pathlib import Path
import pytest
from media_server.config import _restrict_config_perms, _write_yaml_atomic
def test_atomic_write_round_trip():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.yaml"
_write_yaml_atomic(path, {"port": 8765, "host": "127.0.0.1"})
assert path.exists()
# Tmp file from the rename should be gone.
assert not path.with_suffix(path.suffix + ".tmp").exists()
# Contents are valid YAML and round-trip.
import yaml
data = yaml.safe_load(path.read_text())
assert data == {"port": 8765, "host": "127.0.0.1"}
def test_atomic_write_replaces_existing():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.yaml"
path.write_text("old: 1\n")
_write_yaml_atomic(path, {"new": 2})
import yaml
assert yaml.safe_load(path.read_text()) == {"new": 2}
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only permission check")
def test_restrict_config_perms_posix():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.yaml"
path.write_text("token: secret\n")
_restrict_config_perms(path)
mode = stat.S_IMODE(os.stat(path).st_mode)
# Owner read+write only.
assert mode == 0o600, f"got {oct(mode)}"
+83
View File
@@ -0,0 +1,83 @@
"""Smoke tests for the foreground tracker.
The OS-specific probe code is hard to mock end-to-end inside a CI container,
so these tests focus on the platform-agnostic surface: the dataclass shape,
TTL caching, and graceful fallback when the platform probe raises. The
Windows/Linux/macOS probes themselves are exercised through manual runs.
"""
from __future__ import annotations
from media_server.services import foreground_service as fg
def setup_function(_):
fg.reset_cache()
def test_unavailable_default_shape():
info = fg.ForegroundInfo(available=False)
d = info.to_dict()
assert d["available"] is False
assert d["pid"] is None
assert d["process_name"] is None
assert d["is_fullscreen"] is False
assert "platform" in d
def test_cache_returns_same_instance(monkeypatch):
calls = {"n": 0}
def fake_probe():
calls["n"] += 1
return fg.ForegroundInfo(available=True, pid=42, process_name="x.exe")
monkeypatch.setattr(fg, "_probe", fake_probe)
a = fg.get_foreground_info()
b = fg.get_foreground_info()
assert a is b
assert calls["n"] == 1
def test_cache_force_refresh(monkeypatch):
calls = {"n": 0}
def fake_probe():
calls["n"] += 1
return fg.ForegroundInfo(available=True, pid=calls["n"])
monkeypatch.setattr(fg, "_probe", fake_probe)
fg.get_foreground_info()
fg.get_foreground_info(force_refresh=True)
assert calls["n"] == 2
def test_cache_ttl_expiry(monkeypatch):
calls = {"n": 0}
def fake_probe():
calls["n"] += 1
return fg.ForegroundInfo(available=True, pid=calls["n"])
monkeypatch.setattr(fg, "_probe", fake_probe)
monkeypatch.setattr(fg, "_CACHE_TTL", 0.0)
# Re-bind the cache's TTL by exercising it twice with TTL 0.
fg.get_foreground_info()
fg.get_foreground_info()
assert calls["n"] == 2
def test_probe_crash_returns_unavailable(monkeypatch):
def boom():
raise RuntimeError("kaboom")
# Force every platform branch to call our crashing probe.
monkeypatch.setattr(fg, "_probe_windows", boom)
monkeypatch.setattr(fg, "_probe_linux", boom)
monkeypatch.setattr(fg, "_probe_macos", boom)
info = fg._probe()
assert info.available is False
assert info.error and "kaboom" in info.error
+45
View File
@@ -0,0 +1,45 @@
"""Tag-name validation in the Gitea release provider.
Whitelist regex protects the URL we broadcast to clients from any path
traversal or character-set abuse in a hostile (or MITM'd) upstream response.
"""
from __future__ import annotations
from media_server.services.gitea_release_provider import _TAG_RE
def test_accepts_plain_semver():
assert _TAG_RE.match("1.0.0")
assert _TAG_RE.match("v1.0.0")
assert _TAG_RE.match("0.3.7")
def test_accepts_pre_release_suffix():
assert _TAG_RE.match("v1.0.0-alpha.1")
assert _TAG_RE.match("v2.3.4-rc.10")
assert _TAG_RE.match("v0.2.7+build.42")
def test_rejects_path_traversal():
assert not _TAG_RE.match("../etc/passwd")
assert not _TAG_RE.match("v1.0.0/../../evil")
assert not _TAG_RE.match("v1.0.0/secret")
def test_rejects_url_injection():
assert not _TAG_RE.match("v1.0.0?evil=1")
assert not _TAG_RE.match("v1.0.0#frag")
assert not _TAG_RE.match("v1.0.0 OR 1=1")
assert not _TAG_RE.match("https://evil.example/")
def test_rejects_empty_and_garbage():
assert not _TAG_RE.match("")
assert not _TAG_RE.match("not-a-version")
assert not _TAG_RE.match("v")
def test_rejects_excessively_long_suffix():
long_suffix = "x" * 40
assert not _TAG_RE.match(f"v1.0.0-{long_suffix}")
+68
View File
@@ -0,0 +1,68 @@
"""Token-bucket rate limiter behaviour."""
from __future__ import annotations
import time
import pytest
from media_server.services import rate_limit
@pytest.fixture(autouse=True)
def _reset_state():
rate_limit._state.clear()
rate_limit._LAST_CLEANUP = 0.0
yield
rate_limit._state.clear()
def test_allows_up_to_capacity_then_blocks(monkeypatch):
"""Default execute bucket = 10/min."""
peer = "10.0.0.1"
for i in range(10):
ok, retry = rate_limit.check("execute", peer)
assert ok, f"expected allow on attempt {i + 1}, got block (retry={retry})"
ok, retry = rate_limit.check("execute", peer)
assert ok is False
assert retry is not None and retry > 0
def test_different_peers_independent():
for _ in range(10):
assert rate_limit.check("execute", "10.0.0.1")[0]
# Different peer should still be allowed.
assert rate_limit.check("execute", "10.0.0.2")[0]
def test_unknown_bucket_uses_default():
peer = "10.0.0.3"
# default = 60/min — first call always allowed.
allowed, _ = rate_limit.check("nonexistent-bucket", peer)
assert allowed
def test_auth_bucket_is_strict():
"""auth bucket = 5/min."""
peer = "10.0.0.4"
for _ in range(5):
assert rate_limit.check("auth", peer)[0]
blocked, retry = rate_limit.check("auth", peer)
assert not blocked
assert retry is not None
def test_refill_eventually_unblocks(monkeypatch):
"""Verify the bucket refills — exhaust then wait one refill period."""
peer = "10.0.0.5"
# Replace BUCKETS with a fast-refilling one for the test only.
monkeypatch.setitem(
rate_limit.BUCKETS,
"fast-test",
rate_limit.BucketConfig(capacity=2, refill_per_sec=10.0),
)
assert rate_limit.check("fast-test", peer)[0]
assert rate_limit.check("fast-test", peer)[0]
assert not rate_limit.check("fast-test", peer)[0]
time.sleep(0.15) # 0.15 * 10 = 1.5 tokens
assert rate_limit.check("fast-test", peer)[0]
+77
View File
@@ -0,0 +1,77 @@
"""Validation rules for script parameters (type coercion, regex pattern)."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from media_server.config import ScriptParameterConfig
from media_server.routes.scripts import _validate_params
def _defs(**kwargs) -> dict[str, ScriptParameterConfig]:
return {name: ScriptParameterConfig(**spec) for name, spec in kwargs.items()}
def test_unknown_param_rejected():
with pytest.raises(HTTPException) as ei:
_validate_params({"x": "1"}, _defs())
assert ei.value.status_code == 400
assert "Unknown" in ei.value.detail
def test_missing_required_rejected():
defs = _defs(name={"type": "string", "required": True})
with pytest.raises(HTTPException, match="missing"):
_validate_params({}, defs)
def test_integer_coercion_and_bounds():
defs = _defs(volume={"type": "integer", "min": 0, "max": 100})
out = _validate_params({"volume": "42"}, defs)
assert out == {"SCRIPT_PARAM_VOLUME": "42"}
with pytest.raises(HTTPException, match="<="):
_validate_params({"volume": 200}, defs)
with pytest.raises(HTTPException, match=">="):
_validate_params({"volume": -1}, defs)
with pytest.raises(HTTPException, match="integer"):
_validate_params({"volume": "not-a-number"}, defs)
def test_boolean_coercion():
defs = _defs(flag={"type": "boolean"})
assert _validate_params({"flag": "true"}, defs) == {"SCRIPT_PARAM_FLAG": "True"}
assert _validate_params({"flag": "no"}, defs) == {"SCRIPT_PARAM_FLAG": "False"}
with pytest.raises(HTTPException, match="boolean"):
_validate_params({"flag": "maybe"}, defs)
def test_select_rejects_non_option():
defs = _defs(mode={"type": "select", "options": ["a", "b", "c"]})
assert _validate_params({"mode": "a"}, defs) == {"SCRIPT_PARAM_MODE": "a"}
with pytest.raises(HTTPException, match="must be one of"):
_validate_params({"mode": "z"}, defs)
def test_pattern_enforced_on_string():
"""Regex pattern is the defence against shell metachars in shell=true scripts."""
defs = _defs(host={"type": "string", "pattern": r"^[a-z0-9.\-]+$"})
assert _validate_params({"host": "example.com"}, defs) == {"SCRIPT_PARAM_HOST": "example.com"}
with pytest.raises(HTTPException, match="pattern"):
_validate_params({"host": "evil & calc.exe"}, defs)
with pytest.raises(HTTPException, match="pattern"):
_validate_params({"host": "$(rm -rf /)"}, defs)
def test_pattern_can_disallow_empty():
defs = _defs(host={"type": "string", "pattern": r"^[a-z]+$"})
with pytest.raises(HTTPException, match="pattern"):
_validate_params({"host": ""}, defs)
def test_invalid_pattern_in_config_fails_closed():
defs = _defs(host={"type": "string", "pattern": r"["}) # unmatched bracket
with pytest.raises(HTTPException) as ei:
_validate_params({"host": "x"}, defs)
assert ei.value.status_code == 500