mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 00:00:21 +02:00
Real-Frame testing (2026-09-29) found an unworn headset enters standby within seconds; SetOverlayRaw then returns RequestFailed (23) and the movie died. The player now drops frames during standby, keeps audio and pacing, re-sends stills and the theatre surround after waking, and only errors after five minutes without an accepted frame. A Stop arriving while the player is already shutting down is ignored, so a finished video stays 'ended' instead of 'error: Stopped'. The status now reports the layout's real source (filename/metadata). Docs record the end-to-end device matrix (API, web UI, CLI). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
110 lines
4.7 KiB
Python
110 lines
4.7 KiB
Python
"""Frame-side library and process ownership for Frame Control media.
|
|
|
|
Only the dedicated systemd user unit is controlled. No SteamVR settings change.
|
|
"""
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
import frame_media
|
|
from frame_media_player import probe
|
|
|
|
ROOT = Path.home() / 'Videos' / 'FrameControl'
|
|
RUNTIME = Path.home() / '.local' / 'share' / 'frame-control' / 'media'
|
|
UNIT = 'frame-control-media.service'
|
|
STATUS = RUNTIME / 'status.json'
|
|
|
|
|
|
def media_path(identity):
|
|
if not isinstance(identity, str) or '\\' in identity or '\x00' in identity:
|
|
raise ValueError('Invalid media id')
|
|
parts = Path(identity).parts
|
|
if len(parts) != 2 or not re.fullmatch('[0-9a-f]{32}', parts[0]) or parts[1].startswith('.'):
|
|
raise ValueError('Invalid media id')
|
|
candidate = ROOT / identity
|
|
if candidate.is_symlink() or candidate.parent.is_symlink():
|
|
raise ValueError('Media links are not supported')
|
|
path = candidate.resolve(strict=True)
|
|
if not path.is_file() or ROOT.resolve() not in path.parents:
|
|
raise ValueError('Media file is outside the library')
|
|
return path
|
|
|
|
|
|
def active():
|
|
return subprocess.run(['systemctl', '--user', 'is-active', '--quiet', UNIT]).returncode == 0
|
|
|
|
|
|
def status():
|
|
running = active()
|
|
try:
|
|
state = json.loads(STATUS.read_text())
|
|
except (OSError, ValueError):
|
|
state = {'state': 'idle'}
|
|
if not running and state.get('state') in ('playing', 'starting', 'paused'):
|
|
state = {'state': 'stopped', 'message': 'Player exited; check the media log if this was unexpected'}
|
|
return dict(state, running=running)
|
|
|
|
|
|
def run(body):
|
|
action = body.get('action')
|
|
if action == 'list':
|
|
files = []
|
|
if ROOT.exists():
|
|
for folder in sorted(ROOT.iterdir()):
|
|
if not re.fullmatch('[0-9a-f]{32}', folder.name) or not folder.is_dir() or folder.is_symlink():
|
|
continue
|
|
for path in sorted(folder.iterdir()):
|
|
if path.is_file() and not path.is_symlink() and not path.name.startswith('.'):
|
|
files.append({'id': folder.name+'/'+path.name, 'name': path.name, 'bytes': path.stat().st_size})
|
|
return {'files': files, 'player': status()}
|
|
if action == 'status':
|
|
return status()
|
|
if action == 'stop':
|
|
# --collect unloads the unit after it exits; systemctl then exits 5
|
|
# ("not loaded", verified on the Frame). That's a finished player, not an error.
|
|
stopped = subprocess.run(['systemctl', '--user', 'stop', UNIT], capture_output=True, text=True, timeout=15)
|
|
if stopped.returncode not in (0, 5):
|
|
raise RuntimeError('Could not stop the media player: ' + (stopped.stderr.strip() or 'exit %s' % stopped.returncode))
|
|
return {'message': 'Media player stopped', **status()}
|
|
if action != 'play':
|
|
raise ValueError('Media action must be list, status, play or stop')
|
|
path = media_path(body.get('id'))
|
|
if type(body.get('theatre', False)) is not bool:
|
|
raise ValueError('theatre must be true or false')
|
|
info = {} if path.suffix.lower() == '.splat' else probe(path)[0]
|
|
plan = frame_media.plan(path.name, body.get('layout', 'auto'), info.get('tags'))
|
|
if active():
|
|
raise ValueError('Stop the current media before starting another file')
|
|
# systemd owns the process group and refuses a concurrent start of this name.
|
|
# The runtime cap also cleans up if the controlling computer disconnects.
|
|
subprocess.run(['systemctl', '--user', 'reset-failed', UNIT], stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL, timeout=10)
|
|
STATUS.write_text(json.dumps({'state': 'starting', 'file': path.name}))
|
|
command = ['systemd-run', '--user', '--quiet', '--collect', '--unit='+UNIT,
|
|
'--property=RuntimeMaxSec=14400', '--property=TimeoutStopSec=8',
|
|
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
|
|
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
|
|
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
|
|
'--layout', body.get('layout', 'auto'), '--status', str(STATUS)]
|
|
if body.get('theatre'):
|
|
command.append('--theatre')
|
|
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
|
if started.returncode:
|
|
raise RuntimeError('Could not start the media player: ' + (started.stderr.strip() or 'systemd-run exited %s' % started.returncode))
|
|
return {'message': 'Starting Frame Control media', 'plan': plan}
|
|
|
|
|
|
def main():
|
|
try:
|
|
print(json.dumps(run(json.load(sys.stdin))))
|
|
except Exception as e:
|
|
print(json.dumps({'error': str(e)}))
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|