Files
media-player-server/media_server/services/macos_media.py
T
alexei.dolgolyov d131ba461c
Lint & Test / test (push) Successful in 20s
fix: production-readiness hardening — security, perf, a11y, observability
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

319 lines
11 KiB
Python

"""macOS media controller using AppleScript and system commands."""
import asyncio
import logging
import subprocess
from typing import Optional
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
logger = logging.getLogger(__name__)
class MacOSMediaController(MediaController):
"""Media controller for macOS using osascript and system commands."""
def _run_osascript(self, script: str) -> Optional[str]:
"""Run an AppleScript and return the output."""
try:
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
return None
except Exception as e:
logger.error(f"osascript error: {e}")
return None
def _get_active_app(self) -> Optional[str]:
"""Get the currently active media application."""
# Check common media apps in order of preference
apps = ["Spotify", "Music", "TV", "VLC", "QuickTime Player"]
for app in apps:
script = f'''
tell application "System Events"
if exists (processes where name is "{app}") then
return "{app}"
end if
end tell
return ""
'''
result = self._run_osascript(script)
if result:
return result
return None
def _get_spotify_info(self) -> dict:
"""Get playback info from Spotify."""
script = '''
tell application "Spotify"
if player state is playing then
set currentState to "playing"
else if player state is paused then
set currentState to "paused"
else
set currentState to "stopped"
end if
try
set trackName to name of current track
set artistName to artist of current track
set albumName to album of current track
set trackDuration to duration of current track
set trackPosition to player position
set artUrl to artwork url of current track
on error
set trackName to ""
set artistName to ""
set albumName to ""
set trackDuration to 0
set trackPosition to 0
set artUrl to ""
end try
return currentState & "|" & trackName & "|" & artistName & "|" & albumName & "|" & trackDuration & "|" & trackPosition & "|" & artUrl
end tell
'''
result = self._run_osascript(script)
if result:
parts = result.split("|")
if len(parts) >= 7:
return {
"state": parts[0],
"title": parts[1] or None,
"artist": parts[2] or None,
"album": parts[3] or None,
"duration": float(parts[4]) / 1000 if parts[4] else None, # ms to seconds
"position": float(parts[5]) if parts[5] else None,
"art_url": parts[6] or None,
}
return {}
def _get_music_info(self) -> dict:
"""Get playback info from Apple Music."""
script = '''
tell application "Music"
if player state is playing then
set currentState to "playing"
else if player state is paused then
set currentState to "paused"
else
set currentState to "stopped"
end if
try
set trackName to name of current track
set artistName to artist of current track
set albumName to album of current track
set trackDuration to duration of current track
set trackPosition to player position
on error
set trackName to ""
set artistName to ""
set albumName to ""
set trackDuration to 0
set trackPosition to 0
end try
return currentState & "|" & trackName & "|" & artistName & "|" & albumName & "|" & trackDuration & "|" & trackPosition
end tell
'''
result = self._run_osascript(script)
if result:
parts = result.split("|")
if len(parts) >= 6:
return {
"state": parts[0],
"title": parts[1] or None,
"artist": parts[2] or None,
"album": parts[3] or None,
"duration": float(parts[4]) if parts[4] else None,
"position": float(parts[5]) if parts[5] else None,
}
return {}
def _get_volume(self) -> tuple[int, bool]:
"""Get system volume and mute state."""
try:
# Get volume level
result = self._run_osascript("output volume of (get volume settings)")
volume = int(result) if result else 100
# Get mute state
result = self._run_osascript("output muted of (get volume settings)")
muted = result == "true"
return volume, muted
except Exception as e:
logger.error(f"Failed to get volume: {e}")
return 100, False
async def get_status(self) -> MediaStatus:
"""Get current media playback status."""
status = MediaStatus()
# Get system volume
volume, muted = self._get_volume()
status.volume = volume
status.muted = muted
# Try to get info from active media app
active_app = self._get_active_app()
if active_app is None:
status.state = MediaState.IDLE
return status
status.source = active_app
if active_app == "Spotify":
info = self._get_spotify_info()
elif active_app == "Music":
info = self._get_music_info()
else:
info = {}
if info:
state = info.get("state", "stopped")
if state == "playing":
status.state = MediaState.PLAYING
elif state == "paused":
status.state = MediaState.PAUSED
else:
status.state = MediaState.STOPPED
status.title = info.get("title")
status.artist = info.get("artist")
status.album = info.get("album")
status.duration = info.get("duration")
status.position = info.get("position")
status.album_art_url = info.get("art_url")
else:
status.state = MediaState.IDLE
return status
async def play(self) -> bool:
"""Resume playback using media key simulation."""
# Use system media key
# Fallback: try specific app
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to play')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to play')
return True
# Use media key simulation
result = subprocess.run(
["osascript", "-e", 'tell application "System Events" to key code 49'],
capture_output=True,
)
return result.returncode == 0
async def pause(self) -> bool:
"""Pause playback."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to pause')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to pause')
return True
return False
async def stop(self) -> bool:
"""Stop playback."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to pause')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to stop')
return True
return False
async def next_track(self) -> bool:
"""Skip to next track."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to next track')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to next track')
return True
return False
async def previous_track(self) -> bool:
"""Go to previous track."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to previous track')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to previous track')
return True
return False
async def set_volume(self, volume: int) -> bool:
"""Set system volume."""
# osascript returns empty string on success and None on failure (the
# _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:
"""Toggle mute state."""
_, current_mute = self._get_volume()
new_mute = not current_mute
self._run_osascript(f"set volume output muted {str(new_mute).lower()}")
return new_mute
async def seek(self, position: float) -> bool:
"""Seek to position in seconds."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript(
f'tell application "Spotify" to set player position to {position}'
)
return True
elif active_app == "Music":
self._run_osascript(
f'tell application "Music" to set player position to {position}'
)
return True
return False
async def open_file(self, file_path: str) -> bool:
"""Open a media file with the default system player (macOS).
Uses the 'open' command to open the file with the default application.
Args:
file_path: Absolute path to the media file
Returns:
True if successful, False otherwise
"""
try:
process = await asyncio.create_subprocess_exec(
'open', file_path,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL
)
await process.wait()
logger.info(f"Opened file with default player: {file_path}")
return True
except Exception as e:
logger.error(f"Failed to open file {file_path}: {e}")
return False