d131ba461c
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.
319 lines
11 KiB
Python
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
|