Skill: yuqian-shi/denon-avr-control (version 0.1.0)

--- name: denon-avr-control description: Control a network-connected Denon AVR/AVC receiver over its classic IP control interface (TCP telnet-style commands or the goform HTTP endpoint), and expose local audio libraries through DLNA/UPnP for receiver playback. Use when asked to power the receiver on/off, change volume, mute, switch inputs, query status, send raw Denon commands, or serve user-chosen music directories to a compatible Denon/HEOS receiver. --- # Denon AVR Control Use this skill to control a Denon receiver on the local network. ## Quick start 1. Identify the receiver host or IP address. 2. Start with a read/query action before making changes. 3. Use `scripts/denon_avr.py` for normal operations. 4. If a model-specific command is missing, read `references/commands.md` and use `--raw`. ## Default workflow ### Query status Run: ```bash python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --status ``` ### Power ```bash python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --power on python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --power off ``` ### Volume ```bash python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --volume 35.5 python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --volume-up 3 python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --volume-down 2 ``` ### Inputs ```bash python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --input tv python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --input game python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --input heos ``` ### Mute ```bash python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --mute on python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --mute off ``` ## Transport choice Prefer the default TCP transport first: ```bash python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --status ``` If the receiver only responds on the HTTP control endpoint: ```bash python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --transport http --status ``` ## Raw commands Use raw mode for model-specific features or when the canned arguments are not enough: ```bash python3 skills/denon-avr-control/scripts/denon_avr.py --host <ip-or-hostname> --raw PW? --raw MV? --raw SI? ``` Read `references/commands.md` for the common command families. ## Local computer playback If the user wants to play local audio files on the computer itself, use `scripts/local_audio_jukebox.py` together with this receiver-control skill. Read `references/local-playback.md` when the task is to: - scan a local directory for songs - play a random song or a short random queue - play tracks that match a user query - build a simple playlist from user-provided terms - stop or inspect the local player state This workflow plays audio on the local computer, so that machine's system output must already be routed to the receiver if sound should come out of the receiver. ## Experimental DLNA push mode If the user wants the Denon itself to fetch and play a local file over the LAN, use `scripts/dlna_push.py`. Read `references/dlna-push.md` when the task is to: - scan a folder for audio files before choosing one - push a random local track directly to the Denon renderer - push a track chosen by user query - push a specific file path to the Denon - stop the remote playback and shut down the temporary HTTP server - support the same approach on Windows and macOS This mode is more cross-platform than local-output playback because it uses Python + HTTP + UPnP/DLNA. It is still experimental and currently strongest for single-track push. ## Real DLNA media server mode If ad-hoc URL push is unreliable on a receiver, use `scripts/simple_dlna_server.py` instead. Read `references/dlna-server.md` when the task is to: - expose one or more user-chosen music directories as a real discoverable DLNA MediaServer - let the user decide the music path(s) instead of assuming a fixed folder - let the Denon browse the library through its Local Music / HEOS path - support a more Windows-friendly and standards-based setup - avoid raw one-off URL playback hacks This mode provides SSDP discovery, a device description, a ContentDirectory service, a ConnectionManager service, and direct HTTP media serving. Prefer this mode for cross-platform local-library playback. ## Guardrails - Query first when you do not know current state. - Send one mutating action at a time unless the user explicitly wants a batch. - Treat input names and sound modes as model-dependent; if a friendly alias fails, switch to `--raw`. - If the user did not provide the receiver address, ask for the IP/hostname or discover it only if they explicitly want network scanning.

path: references/commands.md
content: # Denon AVR IP Control Notes

Use these command patterns for Denon/Marantz-style IP control when the receiver supports the classic protocol.

## Transport

- **TCP:** plain ASCII commands to port `23`, terminated with carriage return (`\r`)
- **HTTP:** `GET /goform/formiPhoneAppDirect.xml?<COMMAND>` on port `80`

Prefer TCP first. Use HTTP if telnet-style control is disabled or unavailable.

## Common commands

### Power

- `PWON`
- `PWSTANDBY`
- `PW?`

### Mute

- `MUON`
- `MUOFF`
- `MU?`

### Main volume

- `MVUP`
- `MVDOWN`
- `MV?`
- `MV35` → set volume to 35
- `MV355` → set volume to 35.5

### Input select

Common examples:

- `SITV`
- `SIBD`
- `SIGAME`
- `SINET`
- `SICD`
- `SIDVD`
- `SIAUX1`
- `SIAUX2`
- `SIBLUETOOTH`
- `SIPHONO`
- `SITUNER`
- `SISAT/CBL`
- `SI?`

Actual available inputs can vary by model and configuration.

### Sound mode

Examples:

- `MSSTEREO`
- `MSDIRECT`
- `MSPURE DIRECT`
- `MSMOVIE`
- `MSMUSIC`
- `MSGAME`
- `MSAUTO`
- `MSDOLBY AUDIO - DOLBY SURROUND`
- `MS?`

Sound mode strings vary more by model than power/volume/input commands. If a write fails, query first with `MS?` and then use `--raw` for model-specific values.

## Response shape

Many devices echo a state string like:

- `PWON`
- `MUOFF`
- `MV45`
- `SITV`

Some writes return no body on success, especially over HTTP.

## Practical workflow

1. Confirm reachability to the receiver host/IP.
2. Query state first with `PW?`, `MV?`, `SI?`, `MS?`.
3. Send one mutating command at a time.
4. If the model behaves differently, fall back to `--raw` with exact strings.


path: references/dlna-push.md
content: # Experimental DLNA push to a Denon receiver

Use this when the user wants the receiver itself to fetch and play a local file over the network, instead of playing on the local computer.

## How it works

1. Select a local audio file.
2. Start a small HTTP server on the local machine to expose that file.
3. Send `SetAVTransportURI` to the Denon MediaRenderer.
4. Send `Play`.
5. Keep the HTTP server alive while the receiver is playing.

## Important constraints

- The local machine and the receiver must be on the same LAN.
- The receiver must be able to reach the local machine's HTTP server.
- Local firewall rules can break playback.
- This is currently **single-track push**. Queue/playlist support can be layered on later.
- File compatibility still depends on what the Denon can decode.

## Basic examples

### Scan a folder

```bash
python3 skills/denon-avr-control/scripts/dlna_push.py scan --root ~/Music
```

### List tracks matching a term

```bash
python3 skills/denon-avr-control/scripts/dlna_push.py list --root ~/Music --query jazz
```

### Push a random local track to the Denon

```bash
python3 skills/denon-avr-control/scripts/dlna_push.py push --root ~/Music --mode random --denon-host <denon-ip>
```

### Push a matching track to the Denon

```bash
python3 skills/denon-avr-control/scripts/dlna_push.py push --root ~/Music --mode match --query "hotel california" --denon-host <denon-ip>
```

### Push an explicit file

```bash
python3 skills/denon-avr-control/scripts/dlna_push.py push --file ~/Music/example.mp3 --denon-host <denon-ip>
```

### Stop playback and shut down the temporary file server

```bash
python3 skills/denon-avr-control/scripts/dlna_push.py stop
```

### Inspect current state

```bash
python3 skills/denon-avr-control/scripts/dlna_push.py status
```

## Windows notes

The script is pure Python and should work on Windows too, but the machine's firewall must allow inbound access to the temporary HTTP port (default `8123`). On Windows, you may need to use a LAN IP explicitly with `--serve-host`.


path: references/dlna-server.md
content: # Real DLNA media server mode

Use this when the receiver does not behave reliably with raw `SetAVTransportURI` or ad-hoc stream URLs.

The bundled `simple_dlna_server.py` script runs a minimal but real UPnP/DLNA MediaServer:
- serves a UPnP device description
- advertises over SSDP discovery
- exposes `ContentDirectory`
- exposes `ConnectionManager`
- serves actual media files over HTTP

## Typical workflow

Let the user choose the music directory or directories. Do not assume a fixed library path.

### 1. Scan one folder

```bash
python3 skills/denon-avr-control/scripts/simple_dlna_server.py scan --root ~/Music
```

### 1b. Scan multiple folders

```bash
python3 skills/denon-avr-control/scripts/simple_dlna_server.py scan --root ~/Music --root ~/Downloads/music --root "D:\\Music"
```

### 2. Start the server

```bash
python3 skills/denon-avr-control/scripts/simple_dlna_server.py serve --root ~/Music --host <lan-ip> --port 8200 --name "OpenClaw Music"
```

### 2b. Start the server with multiple music roots

```bash
python3 skills/denon-avr-control/scripts/simple_dlna_server.py serve --root ~/Music --root ~/Downloads/music --host <lan-ip> --port 8200 --name "OpenClaw Music"
```

Keep that process running.

### Stop the server later

```bash
python3 skills/denon-avr-control/scripts/simple_dlna_server.py stop
```

### 3. On the receiver

- Open the network music / local music area
- Wait for the server to appear
- Browse the exposed library
- Play the desired track

## Notes

- This is a **minimal** MediaServer, not a full commercial DLNA stack.
- It is still much more correct than handing the receiver a one-off raw file URL.
- The user should choose the library path(s). The script accepts repeated `--root` flags.
- Windows support is good because the script uses only the Python standard library. The main caveat is allowing inbound firewall access to the chosen port and to SSDP discovery on UDP 1900.
- If the Denon does not discover it immediately, wait a bit or reopen the Local Music source.
- On receivers that expose multiple source modes, selecting the network music input may be required before playback becomes audible.


path: references/local-playback.md
content: # Local audio playback on the computer

Use this workflow when the user wants to play local music files but the receiver itself is only being used as the sound output target.

## Constraint

The Denon control protocol does not upload arbitrary local files to the AVR for playback.

Instead:

1. Route the computer's audio output to the receiver (AirPlay, HDMI, USB audio, etc.).
2. Use `scripts/local_audio_jukebox.py` to scan and pick tracks.
3. Use `scripts/denon_avr.py` to set receiver power/input/volume if needed.

## Typical commands

### Scan a music directory

```bash
python3 skills/denon-avr-control/scripts/local_audio_jukebox.py scan --root ~/Music
```

### List matches

```bash
python3 skills/denon-avr-control/scripts/local_audio_jukebox.py list --root ~/Music --query jazz
```

### Play one random track

```bash
python3 skills/denon-avr-control/scripts/local_audio_jukebox.py play --root ~/Music --mode random
```

### Play three random tracks

```bash
python3 skills/denon-avr-control/scripts/local_audio_jukebox.py play --root ~/Music --mode random --count 3
```

### Play a specific track by fuzzy substring

```bash
python3 skills/denon-avr-control/scripts/local_audio_jukebox.py play --root ~/Music --mode match --query "hotel california"
```

### Build a simple ordered playlist from comma-separated search terms

```bash
python3 skills/denon-avr-control/scripts/local_audio_jukebox.py play --root ~/Music --mode playlist --query "intro,theme,finale"
```

### Check/stop playback

```bash
python3 skills/denon-avr-control/scripts/local_audio_jukebox.py status
python3 skills/denon-avr-control/scripts/local_audio_jukebox.py stop
```

## Player choice

- Prefer `afplay` on macOS for simple local playback.
- Use `ffplay` if a format is unsupported by `afplay`.


path: scripts/denon_avr.py
content: #!/usr/bin/env python3
import argparse
import socket
import sys
import urllib.parse
import urllib.request

DEFAULT_PORT = 23
DEFAULT_HTTP_PORT = 80

INPUT_MAP = {
    'tv': 'SITV',
    'cd': 'SICD',
    'dvd': 'SIDVD',
    'bluray': 'SIBD',
    'bd': 'SIBD',
    'game': 'SIGAME',
    'media-player': 'SIMPLAY',
    'mplay': 'SIMPLAY',
    'cbl-sat': 'SISAT/CBL',
    'sat-cbl': 'SISAT/CBL',
    'aux1': 'SIAUX1',
    'aux2': 'SIAUX2',
    'tuner': 'SITUNER',
    'heos': 'SINET',
    'net': 'SINET',
    'bluetooth': 'SIBLUETOOTH',
    'phono': 'SIPHONO',
}

SOUND_MODE_MAP = {
    'stereo': 'MSSTEREO',
    'direct': 'MSDIRECT',
    'pure-direct': 'MSPURE DIRECT',
    'dolby-audio': 'MSDOLBY AUDIO - DOLBY SURROUND',
    'movie': 'MSMOVIE',
    'music': 'MSMUSIC',
    'game': 'MSGAME',
    'auto': 'MSAUTO',
}


def send_tcp(host: str, port: int, command: str, timeout: float) -> str:
    data = (command + '\r').encode('ascii')
    with socket.create_connection((host, port), timeout=timeout) as sock:
        sock.settimeout(timeout)
        sock.sendall(data)
        try:
            return sock.recv(4096).decode('utf-8', 'ignore').strip()
        except socket.timeout:
            return ''


def send_http(host: str, port: int, command: str, timeout: float) -> str:
    encoded = urllib.parse.quote(command, safe='')
    url = f'http://{host}:{port}/goform/formiPhoneAppDirect.xml?{encoded}'
    req = urllib.request.Request(url, method='GET')
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.read().decode('utf-8', 'ignore').strip()


def format_volume(value: float) -> str:
    if value < 0 or value > 98:
        raise ValueError('Volume must be between 0 and 98 dB steps in Denon absolute scale.')
    whole = int(value)
    frac = round(value - whole, 1)
    if frac == 0:
        return f'{whole:02d}'
    if frac == 0.5:
        return f'{whole:02d}5'
    raise ValueError('Volume must be in 0.5 increments, e.g. 35 or 35.5')


def build_commands(args: argparse.Namespace) -> list[str]:
    commands: list[str] = []

    if args.raw:
        commands.extend(args.raw)

    if args.power:
        if args.power == 'on':
            commands.append('PWON')
        elif args.power == 'off':
            commands.append('PWSTANDBY')
        elif args.power == 'status':
            commands.append('PW?')

    if args.mute:
        commands.append({'on': 'MUON', 'off': 'MUOFF', 'toggle': 'MUTOGGLE', 'status': 'MU?'}[args.mute])

    if args.volume is not None:
        commands.append('MV' + format_volume(args.volume))
    if args.volume_up:
        commands.extend(['MVUP'] * args.volume_up)
    if args.volume_down:
        commands.extend(['MVDOWN'] * args.volume_down)
    if args.volume_status:
        commands.append('MV?')

    if args.input:
        key = args.input.strip().lower()
        if key not in INPUT_MAP:
            raise ValueError(f'Unknown input: {args.input}')
        commands.append(INPUT_MAP[key])
    if args.input_status:
        commands.append('SI?')

    if args.sound_mode:
        key = args.sound_mode.strip().lower()
        if key not in SOUND_MODE_MAP:
            raise ValueError(f'Unknown sound mode: {args.sound_mode}')
        commands.append(SOUND_MODE_MAP[key])
    if args.sound_mode_status:
        commands.append('MS?')

    if args.zone:
        zone = args.zone.upper()
        zone_cmd = zone + '?'
        commands.append(zone_cmd)

    if args.status:
        commands.extend(['PW?', 'MU?', 'MV?', 'SI?', 'MS?'])

    if not commands:
        raise ValueError('No command requested. Use --status, --power, --volume, --input, or --raw.')

    return commands


def main() -> int:
    parser = argparse.ArgumentParser(description='Control a Denon AVR over TCP or HTTP IP control.')
    parser.add_argument('--host', required=True, help='Receiver hostname or IP address')
    parser.add_argument('--transport', choices=['tcp', 'http'], default='tcp')
    parser.add_argument('--port', type=int, help='Override port (default: 23 for tcp, 80 for http)')
    parser.add_argument('--timeout', type=float, default=3.0)

    parser.add_argument('--status', action='store_true', help='Query common status values')
    parser.add_argument('--power', choices=['on', 'off', 'status'])
    parser.add_argument('--mute', choices=['on', 'off', 'toggle', 'status'])
    parser.add_argument('--volume', type=float, help='Set absolute volume in 0.5 steps, e.g. 35.5')
    parser.add_argument('--volume-up', type=int, default=0, help='Increment volume N steps')
    parser.add_argument('--volume-down', type=int, default=0, help='Decrement volume N steps')
    parser.add_argument('--volume-status', action='store_true')
    parser.add_argument('--input', help='Select input: tv, bluray, game, heos, cd, dvd, tuner, aux1, aux2, bluetooth, phono, cbl-sat')
    parser.add_argument('--input-status', action='store_true')
    parser.add_argument('--sound-mode', help='Set sound mode: stereo, direct, pure-direct, movie, music, game, auto, dolby-audio')
    parser.add_argument('--sound-mode-status', action='store_true')
    parser.add_argument('--zone', choices=['ZM', 'Z2', 'Z3'], help='Query a raw zone status token')
    parser.add_argument('--raw', action='append', help='Send a raw Denon command. Repeat for multiple commands.')

    args = parser.parse_args()

    try:
        commands = build_commands(args)
    except ValueError as e:
        print(f'error: {e}', file=sys.stderr)
        return 2

    port = args.port or (DEFAULT_PORT if args.transport == 'tcp' else DEFAULT_HTTP_PORT)
    sender = send_tcp if args.transport == 'tcp' else send_http

    exit_code = 0
    for command in commands:
        try:
            response = sender(args.host, port, command, args.timeout)
            print(f'> {command}')
            if response:
                print(response)
        except Exception as e:
            exit_code = 1
            print(f'> {command}', file=sys.stderr)
            print(f'error: {e}', file=sys.stderr)
            break

    return exit_code


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/dlna_push.py
content: #!/usr/bin/env python3
import argparse
import html
import json
import mimetypes
import os
import random
import signal
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from xml.sax.saxutils import escape

AUDIO_EXTS = {'.mp3', '.m4a', '.aac', '.wav', '.flac', '.aiff', '.ogg'}
STATE_DIR = Path.home() / '.openclaw' / 'state'
STATE_FILE = STATE_DIR / 'denon-dlna-push.json'
DEFAULT_BASE = 'http://{host}:60006'


def state_load():
    if not STATE_FILE.exists():
        return {}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception:
        return {}


def state_save(data):
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))


def is_running(pid):
    try:
        os.kill(pid, 0)
        return True
    except OSError:
        return False


def local_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(('8.8.8.8', 80))
        return s.getsockname()[0]
    finally:
        s.close()


def scan_audio(root: Path):
    files = []
    for p in root.rglob('*'):
        if p.is_file() and p.suffix.lower() in AUDIO_EXTS:
            files.append(p)
    return sorted(files, key=lambda p: str(p).lower())


def choose(files, mode, query=None, count=1):
    if mode == 'random':
        if not files:
            return []
        return random.sample(files, max(1, min(count, len(files))))
    if mode == 'match':
        q = (query or '').strip().lower()
        return [f for f in files if q in f.name.lower() or q in str(f).lower()]
    raise ValueError(f'unsupported mode: {mode}')


def content_type_for(path: Path) -> str:
    t, _ = mimetypes.guess_type(str(path))
    if t:
        return t
    return 'application/octet-stream'


def didl_for(url: str, path: Path) -> str:
    title = escape(path.stem)
    ctype = content_type_for(path)
    protocol_info = f'http-get:*:{ctype}:*'
    return (
        '<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" '
        'xmlns:dc="http://purl.org/dc/elements/1.1/" '
        'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">'
        '<item id="0" parentID="0" restricted="1">'
        f'<dc:title>{title}</dc:title>'
        '<upnp:class>object.item.audioItem.musicTrack</upnp:class>'
        f'<res protocolInfo="{escape(protocol_info)}">{escape(url)}</res>'
        '</item>'
        '</DIDL-Lite>'
    )


def soap_envelope(service, action, args):
    body = ''.join(f'<{k}>{html.escape(str(v))}</{k}>' for k, v in args.items())
    return (
        '<?xml version="1.0" encoding="utf-8"?>'
        '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
        's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
        '<s:Body>'
        f'<u:{action} xmlns:u="{service}">{body}</u:{action}>'
        '</s:Body>'
        '</s:Envelope>'
    ).encode('utf-8')


def soap_post(base_url, control_url, service, action, args, timeout=8):
    data = soap_envelope(service, action, args)
    req = urllib.request.Request(
        base_url + control_url,
        data=data,
        method='POST',
        headers={
            'Content-Type': 'text/xml; charset="utf-8"',
            'SOAPACTION': f'"{service}#{action}"',
        },
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.read().decode('utf-8', 'ignore')


def start_http_server(root_dir: Path, host: str, port: int):
    cmd = [sys.executable, '-m', 'http.server', str(port), '--bind', host, '--directory', str(root_dir)]
    proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    time.sleep(0.8)
    if proc.poll() is not None:
        raise RuntimeError('HTTP server exited immediately')
    return proc


def print_json(obj):
    print(json.dumps(obj, ensure_ascii=False, indent=2))


def cmd_scan(args):
    root = Path(os.path.expanduser(args.root)).resolve()
    files = scan_audio(root)
    print_json({'root': str(root), 'count': len(files), 'sample': [str(p) for p in files[:args.limit]]})
    return 0


def cmd_list(args):
    root = Path(os.path.expanduser(args.root)).resolve()
    files = scan_audio(root)
    q = (args.query or '').strip().lower()
    if q:
        files = [f for f in files if q in f.name.lower() or q in str(f).lower()]
    print_json({'root': str(root), 'count': len(files), 'matches': [str(p) for p in files[:args.limit]]})
    return 0


def resolve_selection(args):
    if args.file:
        p = Path(os.path.expanduser(args.file)).resolve()
        if not p.exists():
            raise FileNotFoundError(p)
        return [p]
    root = Path(os.path.expanduser(args.root)).resolve()
    files = scan_audio(root)
    return choose(files, args.mode, args.query, args.count)


def cmd_push(args):
    tracks = resolve_selection(args)
    if not tracks:
        print('error: no tracks selected', file=sys.stderr)
        return 1
    track = tracks[0]
    serve_ip = args.serve_host or local_ip()
    serve_port = args.serve_port
    denon = args.denon_host
    if not denon and not args.base_url:
        raise ValueError('Provide --denon-host or --base-url')
    base = args.base_url or DEFAULT_BASE.format(host=denon)

    http_root = track.parent
    server = start_http_server(http_root, serve_ip, serve_port)
    file_url = f'http://{serve_ip}:{serve_port}/{urllib.request.pathname2url(track.name)}'
    metadata = didl_for(file_url, track)

    state = {
        'httpPid': server.pid,
        'httpRoot': str(http_root),
        'file': str(track),
        'fileUrl': file_url,
        'denonHost': denon,
        'baseUrl': base,
        'updatedAt': int(time.time()),
    }
    state_save(state)

    try:
        set_resp = soap_post(base, [REDACTED_SECRET], 'urn:schemas-upnp-org:service:AVTransport:1', 'SetAVTransportURI', {
            'InstanceID': 0,
            'CurrentURI': file_url,
            'CurrentURIMetaData': metadata,
        })
        play_resp = soap_post(base, [REDACTED_SECRET], 'urn:schemas-upnp-org:service:AVTransport:1', 'Play', {
            'InstanceID': 0,
            'Speed': 1,
        })
    except Exception:
        try:
            os.kill(server.pid, signal.SIGTERM)
        except Exception:
            pass
        raise

    print_json({
        'selected': [str(t) for t in tracks],
        'pushed': str(track),
        'fileUrl': file_url,
        'httpPid': server.pid,
        'setResponse': set_resp[:800],
        'playResponse': play_resp[:800],
        'note': 'HTTP server stays running so the Denon can fetch the file. Use stop to terminate it.',
    })
    return 0


def cmd_stop(args):
    st = state_load()
    base = st.get('baseUrl')
    if not base:
        denon = st.get('denonHost')
        if not denon:
            print_json({'error': 'No saved baseUrl or denonHost. Nothing to stop.'})
            return 1
        base = DEFAULT_BASE.format(host=denon)
    try:
        resp = soap_post(base, [REDACTED_SECRET], 'urn:schemas-upnp-org:service:AVTransport:1', 'Stop', {
            'InstanceID': 0,
        })
    except Exception as e:
        resp = f'stop rpc failed: {e}'
    pid = st.get('httpPid')
    killed = False
    if pid and is_running(pid):
        try:
            os.kill(pid, signal.SIGTERM)
            killed = True
        except Exception:
            pass
    st['updatedAt'] = int(time.time())
    st['stopped'] = True
    state_save(st)
    print_json({'transportStop': resp[:800] if isinstance(resp, str) else resp, 'httpPid': pid, 'serverKilled': killed})
    return 0


def cmd_status(args):
    st = state_load()
    if not st:
        print_json({'state': 'empty'})
        return 0
    pid = st.get('httpPid')
    st['httpRunning'] = bool(pid and is_running(pid))
    print_json(st)
    return 0


def main():
    parser = argparse.ArgumentParser(description='Experimental DLNA push of local audio files to a Denon MediaRenderer.')
    sub = parser.add_subparsers(dest='cmd', required=True)

    p = sub.add_parser('scan')
    p.add_argument('--root', required=True)
    p.add_argument('--limit', type=int, default=100)
    p.set_defaults(func=cmd_scan)

    p = sub.add_parser('list')
    p.add_argument('--root', required=True)
    p.add_argument('--query')
    p.add_argument('--limit', type=int, default=50)
    p.set_defaults(func=cmd_list)

    p = sub.add_parser('push')
    p.add_argument('--root')
    p.add_argument('--file')
    p.add_argument('--mode', choices=['random', 'match'], default='match')
    p.add_argument('--query')
    p.add_argument('--count', type=int, default=1)
    p.add_argument('--denon-host', help='Denon receiver IP or hostname')
    p.add_argument('--base-url')
    p.add_argument('--serve-host', help='Local IP address to expose the HTTP file server on; auto-detected by default')
    p.add_argument('--serve-port', type=int, default=8123)
    p.set_defaults(func=cmd_push)

    p = sub.add_parser('stop')
    p.set_defaults(func=cmd_stop)

    p = sub.add_parser('status')
    p.set_defaults(func=cmd_status)

    args = parser.parse_args()
    try:
        return args.func(args)
    except Exception as e:
        print(f'error: {e}', file=sys.stderr)
        return 1


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/local_audio_jukebox.py
content: #!/usr/bin/env python3
import argparse
import json
import os
import random
import signal
import subprocess
import sys
import time
from pathlib import Path

AUDIO_EXTS = {'.mp3', '.m4a', '.aac', '.wav', '.flac', '.aiff', '.ogg'}
STATE_DIR = Path.home() / '.openclaw' / 'state'
STATE_FILE = STATE_DIR / 'local-audio-jukebox.json'


def scan_audio(root: Path):
    files = []
    for p in root.rglob('*'):
        if p.is_file() and p.suffix.lower() in AUDIO_EXTS:
            files.append(p)
    return sorted(files, key=lambda p: str(p).lower())


def load_state():
    if not STATE_FILE.exists():
        return {}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception:
        return {}


def save_state(state):
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))


def is_running(pid: int) -> bool:
    try:
        os.kill(pid, 0)
        return True
    except OSError:
        return False


def stop_current():
    state = load_state()
    pid = state.get('pid')
    if not pid:
        print('No active player recorded.')
        return 0
    if not is_running(pid):
        print('Recorded player is not running.')
        return 0
    try:
        os.kill(pid, signal.SIGTERM)
        print(f'Stopped player pid {pid}.')
        return 0
    except Exception as e:
        print(f'error: {e}', file=sys.stderr)
        return 1


def print_status():
    state = load_state()
    if not state:
        print('No player state recorded.')
        return 0
    pid = state.get('pid')
    running = bool(pid and is_running(pid))
    print(json.dumps({
        'running': running,
        'pid': pid,
        'root': state.get('root'),
        'mode': state.get('mode'),
        'query': state.get('query'),
        'tracks': state.get('tracks', []),
        'updatedAt': state.get('updatedAt'),
    }, ensure_ascii=False, indent=2))
    return 0


def play_worker(tracks, player):
    for track in tracks:
        if player == 'afplay':
            cmd = ['afplay', str(track)]
        else:
            cmd = ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'error', str(track)]
        proc = subprocess.Popen(cmd)
        rc = proc.wait()
        if rc != 0:
            print(f'warning: player exited with {rc} for {track}', file=sys.stderr)
            break


def choose_tracks(files, mode, query=None, count=1):
    if mode == 'random':
        if not files:
            return []
        count = max(1, min(count, len(files)))
        return random.sample(files, count)
    if mode == 'match':
        q = (query or '').strip().lower()
        matches = [f for f in files if q in f.name.lower() or q in str(f).lower()]
        return matches
    if mode == 'playlist':
        if not query:
            return files
        terms = [q.strip().lower() for q in query.split(',') if q.strip()]
        selected = []
        for term in terms:
            for f in files:
                hay = (f.name + ' ' + str(f)).lower()
                if term in hay and f not in selected:
                    selected.append(f)
                    break
        return selected
    raise ValueError(f'Unsupported mode: {mode}')


def main():
    parser = argparse.ArgumentParser(description='Scan local audio directories and play matches or random selections.')
    sub = parser.add_subparsers(dest='cmd', required=True)

    scan_p = sub.add_parser('scan')
    scan_p.add_argument('--root', required=True)
    scan_p.add_argument('--limit', type=int, default=100)

    list_p = sub.add_parser('list')
    list_p.add_argument('--root', required=True)
    list_p.add_argument('--query')
    list_p.add_argument('--limit', type=int, default=50)

    play_p = sub.add_parser('play')
    play_p.add_argument('--root', required=True)
    play_p.add_argument('--mode', choices=['random', 'match', 'playlist'], required=True)
    play_p.add_argument('--query')
    play_p.add_argument('--count', type=int, default=1)
    play_p.add_argument('--player', choices=['afplay', 'ffplay'], default='afplay')
    play_p.add_argument('--dry-run', action='store_true')

    sub.add_parser('stop')
    sub.add_parser('status')

    args = parser.parse_args()

    if args.cmd == 'stop':
        return stop_current()
    if args.cmd == 'status':
        return print_status()

    root = Path(os.path.expanduser(args.root)).resolve()
    if not root.exists() or not root.is_dir():
        print(f'error: root directory not found: {root}', file=sys.stderr)
        return 2

    files = scan_audio(root)

    if args.cmd == 'scan':
        print(json.dumps({
            'root': str(root),
            'count': len(files),
            'sample': [str(p) for p in files[:args.limit]],
        }, ensure_ascii=False, indent=2))
        return 0

    if args.cmd == 'list':
        q = (args.query or '').strip().lower()
        if q:
            files = [f for f in files if q in f.name.lower() or q in str(f).lower()]
        print(json.dumps({
            'root': str(root),
            'count': len(files),
            'matches': [str(p) for p in files[:args.limit]],
        }, ensure_ascii=False, indent=2))
        return 0

    tracks = choose_tracks(files, args.mode, args.query, args.count)
    if not tracks:
        print('error: no matching tracks found', file=sys.stderr)
        return 1

    state = {
        'pid': os.getpid(),
        'root': str(root),
        'mode': args.mode,
        'query': args.query,
        'tracks': [str(t) for t in tracks],
        'updatedAt': int(time.time()),
    }
    save_state(state)

    if args.dry_run:
        print(json.dumps(state, ensure_ascii=False, indent=2))
        return 0

    print(json.dumps(state, ensure_ascii=False, indent=2))
    play_worker(tracks, args.player)
    state['updatedAt'] = int(time.time())
    save_state(state)
    return 0


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/simple_dlna_server.py
content: #!/usr/bin/env python3
import argparse
import html
import json
import mimetypes
import os
import signal
import socket
import struct
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote, urlparse
from xml.etree import ElementTree as ET

AUDIO_EXTS = {'.mp3', '.m4a', '.aac', '.wav', '.flac', '.aiff', '.ogg'}
STATE_DIR = Path.home() / '.openclaw' / 'state'
STATE_FILE = STATE_DIR / 'simple-dlna-server.json'
URN_DEVICE = 'urn:schemas-upnp-org:device:MediaServer:1'
URN_CONTENT = 'urn:schemas-upnp-org:service:ContentDirectory:1'
URN_CONN = 'urn:schemas-upnp-org:service:ConnectionManager:1'


def save_state(data):
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))


def load_state():
    if not STATE_FILE.exists():
        return {}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception:
        return {}


def local_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(('8.8.8.8', 80))
        return s.getsockname()[0]
    finally:
        s.close()


def resolve_roots(root_args):
    roots = []
    for raw in root_args or []:
        root = Path(os.path.expanduser(raw)).resolve()
        if not root.exists() or not root.is_dir():
            raise FileNotFoundError(root)
        roots.append(root)
    if not roots:
        raise ValueError('At least one --root path is required')
    return roots


def scan_library(roots):
    items = []
    seen = set()
    for root in roots:
        for p in root.rglob('*'):
            if not (p.is_file() and p.suffix.lower() in AUDIO_EXTS):
                continue
            rp = str(p.resolve())
            if rp in seen:
                continue
            seen.add(rp)
            rel = p.relative_to(root)
            items.append({
                'id': str(len(items) + 1),
                'name': p.stem,
                'path': rp,
                'root': str(root),
                'rel': str(rel).replace('\\', '/'),
                'size': p.stat().st_size,
                'mime': mimetypes.guess_type(str(p))[0] or 'application/octet-stream',
            })
    return items


def xml_text(tag, text):
    return f'<{tag}>{html.escape(str(text))}</{tag}>'


def didl_for_items(base_url: str, items):
    out = [
        '<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" '
        'xmlns:dc="http://purl.org/dc/elements/1.1/" '
        'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">'
    ]
    for item in items:
        url = f'{base_url}/media/{quote(item["id"])}'
        out.append(
            f'<item id="{html.escape(item["id"])}" parentID="0" restricted="1">'
            f'{xml_text("dc:title", item["name"])}'
            '<upnp:class>object.item.audioItem.musicTrack</upnp:class>'
            f'<res protocolInfo="http-get:*:{html.escape(item["mime"])}:*" size="{item["size"]}">{html.escape(url)}</res>'
            '</item>'
        )
    out.append('</DIDL-Lite>')
    return ''.join(out)


def browse_response(base_url: str, items, object_id: str, start_index: int, count: int):
    if object_id != '0':
        selected = [x for x in items if x['id'] == object_id]
    else:
        selected = items
    total = len(selected)
    if count == 0:
        page = selected[start_index:]
    else:
        page = selected[start_index:start_index + count]
    didl = didl_for_items(base_url, page)
    return (
        '<u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">'
        f'{xml_text("Result", didl)}'
        f'{xml_text("NumberReturned", len(page))}'
        f'{xml_text("TotalMatches", total)}'
        f'{xml_text("UpdateID", 1)}'
        '</u:BrowseResponse>'
    )


def soap_envelope(body: str):
    return (
        '<?xml version="1.0" encoding="utf-8"?>'
        '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
        's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
        f'<s:Body>{body}</s:Body>'
        '</s:Envelope>'
    ).encode('utf-8')


class DlnaHandler(BaseHTTPRequestHandler):
    server_version = 'OpenClawDLNA/0.1'

    def log_message(self, fmt, *args):
        print('HTTP', self.address_string(), fmt % args, flush=True)

    @property
    def state(self):
        return self.server.state

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == '/description.xml':
            self.send_xml(self.device_description())
            return
        if parsed.path == '/scpd/content_directory.xml':
            self.send_xml(self.content_scpd())
            return
        if parsed.path == '/scpd/connection_manager.xml':
            self.send_xml(self.conn_scpd())
            return
        if parsed.path.startswith('/media/'):
            media_id = parsed.path.split('/')[-1]
            item = next((x for x in self.state['items'] if x['id'] == media_id), None)
            if not item:
                self.send_error(404, 'Not found')
                return
            path = Path(item['path'])
            if not path.exists():
                self.send_error(404, 'File missing')
                return
            self.send_response(200)
            self.send_header('Content-Type', item['mime'])
            self.send_header('Content-Length', str(item['size']))
            self.send_header('Accept-Ranges', 'bytes')
            self.end_headers()
            with path.open('rb') as f:
                while True:
                    chunk = f.read(1024 * 64)
                    if not chunk:
                        break
                    self.wfile.write(chunk)
            return
        self.send_error(404, 'Not found')

    def do_POST(self):
        parsed = urlparse(self.path)
        length = int(self.headers.get('Content-Length', '0'))
        body = self.rfile.read(length)
        if parsed.path == '/upnp/control/content_directory':
            self.handle_content_directory(body)
            return
        if parsed.path == '/upnp/control/connection_manager':
            self.handle_connection_manager(body)
            return
        self.send_error(404, 'Not found')

    def send_xml(self, text: str):
        data = text.encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/xml; charset="utf-8"')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def device_description(self):
        base = self.state['base_url']
        udn = self.state['udn']
        name = self.state['name']
        return f'''<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
  <specVersion><major>1</major><minor>0</minor></specVersion>
  <URLBase>{html.escape(base)}/</URLBase>
  <device>
    <deviceType>{URN_DEVICE}</deviceType>
    <friendlyName>{html.escape(name)}</friendlyName>
    <manufacturer>OpenClaw</manufacturer>
    <manufacturerURL>https://docs.openclaw.ai</manufacturerURL>
    <modelDescription>OpenClaw simple audio DLNA server</modelDescription>
    <modelName>OpenClaw DLNA</modelName>
    <modelNumber>0.1</modelNumber>
    <UDN>{html.escape(udn)}</UDN>
    <serviceList>
      <service>
        <serviceType>{URN_CONTENT}</serviceType>
        <serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
        <SCPDURL>/scpd/content_directory.xml</SCPDURL>
        <controlURL>/upnp/control/content_directory</controlURL>
        <eventSubURL>/upnp/event/content_directory</eventSubURL>
      </service>
      <service>
        <serviceType>{URN_CONN}</serviceType>
        <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
        <SCPDURL>/scpd/connection_manager.xml</SCPDURL>
        <controlURL>/upnp/control/connection_manager</controlURL>
        <eventSubURL>/upnp/event/connection_manager</eventSubURL>
      </service>
    </serviceList>
  </device>
</root>'''

    def content_scpd(self):
        return '''<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion><major>1</major><minor>0</minor></specVersion>
  <actionList>
    <action><name>Browse</name><argumentList>
      <argument><name>ObjectID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable></argument>
      <argument><name>BrowseFlag</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable></argument>
      <argument><name>Filter</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable></argument>
      <argument><name>StartingIndex</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable></argument>
      <argument><name>RequestedCount</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument>
      <argument><name>SortCriteria</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable></argument>
      <argument><name>Result</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable></argument>
      <argument><name>NumberReturned</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument>
      <argument><name>TotalMatches</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument>
      <argument><name>UpdateID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable></argument>
    </argumentList></action>
    <action><name>GetSearchCapabilities</name><argumentList><argument><name>SearchCaps</name><direction>out</direction><relatedStateVariable>SearchCapabilities</relatedStateVariable></argument></argumentList></action>
    <action><name>GetSortCapabilities</name><argumentList><argument><name>SortCaps</name><direction>out</direction><relatedStateVariable>SortCapabilities</relatedStateVariable></argument></argumentList></action>
    <action><name>GetSystemUpdateID</name><argumentList><argument><name>Id</name><direction>out</direction><relatedStateVariable>SystemUpdateID</relatedStateVariable></argument></argumentList></action>
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="no"><name>A_ARG_TYPE_ObjectID</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>A_ARG_TYPE_BrowseFlag</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>A_ARG_TYPE_Filter</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>A_ARG_TYPE_Index</name><dataType>ui4</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>A_ARG_TYPE_Count</name><dataType>ui4</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>A_ARG_TYPE_SortCriteria</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>A_ARG_TYPE_Result</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>A_ARG_TYPE_UpdateID</name><dataType>ui4</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>SearchCapabilities</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>SortCapabilities</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>SystemUpdateID</name><dataType>ui4</dataType></stateVariable>
  </serviceStateTable>
</scpd>'''

    def conn_scpd(self):
        return '''<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
  <specVersion><major>1</major><minor>0</minor></specVersion>
  <actionList>
    <action><name>GetProtocolInfo</name><argumentList>
      <argument><name>Source</name><direction>out</direction><relatedStateVariable>SourceProtocolInfo</relatedStateVariable></argument>
      <argument><name>Sink</name><direction>out</direction><relatedStateVariable>SinkProtocolInfo</relatedStateVariable></argument>
    </argumentList></action>
    <action><name>GetCurrentConnectionIDs</name><argumentList>
      <argument><name>ConnectionIDs</name><direction>out</direction><relatedStateVariable>CurrentConnectionIDs</relatedStateVariable></argument>
    </argumentList></action>
  </actionList>
  <serviceStateTable>
    <stateVariable sendEvents="no"><name>SourceProtocolInfo</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>SinkProtocolInfo</name><dataType>string</dataType></stateVariable>
    <stateVariable sendEvents="no"><name>CurrentConnectionIDs</name><dataType>string</dataType></stateVariable>
  </serviceStateTable>
</scpd>'''

    def handle_content_directory(self, body: bytes):
        try:
            root = ET.fromstring(body)
        except Exception:
            self.send_error(400, 'Bad XML')
            return
        for elem in root.iter():
            if elem.tag.endswith('Browse'):
                vals = {child.tag.split('}')[-1]: (child.text or '') for child in elem}
                xml = browse_response(
                    self.state['base_url'],
                    self.state['items'],
                    vals.get('ObjectID', '0'),
                    int(vals.get('StartingIndex', '0') or 0),
                    int(vals.get('RequestedCount', '0') or 0),
                )
                self.send_xml(soap_envelope(xml).decode('utf-8'))
                return
            if elem.tag.endswith('GetSearchCapabilities'):
                self.send_xml(soap_envelope('<u:GetSearchCapabilitiesResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><SearchCaps></SearchCaps></u:GetSearchCapabilitiesResponse>').decode('utf-8'))
                return
            if elem.tag.endswith('GetSortCapabilities'):
                self.send_xml(soap_envelope('<u:GetSortCapabilitiesResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><SortCaps></SortCaps></u:GetSortCapabilitiesResponse>').decode('utf-8'))
                return
            if elem.tag.endswith('GetSystemUpdateID'):
                self.send_xml(soap_envelope('<u:GetSystemUpdateIDResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><Id>1</Id></u:GetSystemUpdateIDResponse>').decode('utf-8'))
                return
        self.send_error(400, 'Unsupported action')

    def handle_connection_manager(self, body: bytes):
        text = body.decode('utf-8', 'ignore')
        if 'GetProtocolInfo' in text:
            src = ','.join(sorted({f'http-get:*:{x["mime"]}:*' for x in self.state['items']}))
            xml = f'<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"><Source>{html.escape(src)}</Source><Sink></Sink></u:GetProtocolInfoResponse>'
            self.send_xml(soap_envelope(xml).decode('utf-8'))
            return
        if 'GetCurrentConnectionIDs' in text:
            xml = '<u:GetCurrentConnectionIDsResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"><ConnectionIDs></ConnectionIDs></u:GetCurrentConnectionIDsResponse>'
            self.send_xml(soap_envelope(xml).decode('utf-8'))
            return
        self.send_error(400, 'Unsupported action')


class SsdpResponder(threading.Thread):
    daemon = True

    def __init__(self, host: str, port: int, udn: str):
        super().__init__()
        self.host = host
        self.port = port
        self.udn = udn
        self.stop_event = threading.Event()

    def run(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            sock.bind(('', 1900))
        except Exception:
            return
        mreq = struct.pack('4sl', socket.inet_aton('239.255.255.250'), socket.INADDR_ANY)
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
        sock.settimeout(1)
        location = f'http://{self.host}:{self.port}/description.xml'
        while not self.stop_event.is_set():
            try:
                data, addr = sock.recvfrom(2048)
            except socket.timeout:
                continue
            text = data.decode('utf-8', 'ignore')
            if 'M-SEARCH' not in text or 'ssdp:discover' not in text.lower():
                continue
            if 'upnp:rootdevice' in text or 'ssdp:all' in text.lower() or 'MediaServer:1' in text:
                for st in ['upnp:rootdevice', URN_DEVICE, self.udn]:
                    resp = (
                        'HTTP/1.1 200 OK\r\n'
                        'CACHE-CONTROL: max-age=1800\r\n'
                        'EXT:\r\n'
                        f'LOCATION: {location}\r\n'
                        'SERVER: OpenClaw/1.0 UPnP/1.0 OpenClawDLNA/0.1\r\n'
                        f'ST: {st}\r\n'
                        f'USN: {self.udn}::{st}\r\n\r\n'
                    )
                    sock.sendto(resp.encode('utf-8'), addr)
        sock.close()

    def stop(self):
        self.stop_event.set()


def cmd_scan(args):
    roots = resolve_roots(args.root)
    items = scan_library(roots)
    print(json.dumps({'roots': [str(r) for r in roots], 'count': len(items), 'sample': items[:args.limit]}, ensure_ascii=False, indent=2))
    return 0


def cmd_serve(args):
    roots = resolve_roots(args.root)
    host = args.host or local_ip()
    port = args.port
    items = scan_library(roots)
    udn = f'uuid:{uuid.uuid4()}'
    default_name = roots[0].name if len(roots) == 1 else f'{roots[0].name} + {len(roots)-1} more'
    name = args.name or f'OpenClaw DLNA ({default_name})'
    state = {
        'roots': [str(r) for r in roots],
        'items': items,
        'host': host,
        'port': port,
        'udn': udn,
        'name': name,
        'base_url': f'http://{host}:{port}',
        'pid': os.getpid(),
        'startedAt': int(time.time()),
    }
    save_state(state)
    httpd = ThreadingHTTPServer((host, port), DlnaHandler)
    httpd.state = state
    ssdp = SsdpResponder(host, port, udn)
    ssdp.start()
    print(json.dumps({'status': 'serving', 'description': f'http://{host}:{port}/description.xml', 'items': len(items), 'friendlyName': name}, ensure_ascii=False, indent=2), flush=True)

    def shutdown(*_):
        ssdp.stop()
        threading.Thread(target=httpd.shutdown, daemon=True).start()

    signal.signal(signal.SIGTERM, shutdown)
    signal.signal(signal.SIGINT, shutdown)
    try:
        httpd.serve_forever()
    finally:
        ssdp.stop()
    return 0


def cmd_status(args):
    print(json.dumps(load_state(), ensure_ascii=False, indent=2))
    return 0


def cmd_stop(args):
    st = load_state()
    pid = st.get('pid')
    if not pid:
        print(json.dumps({'stopped': False, 'reason': 'no recorded pid'}, ensure_ascii=False, indent=2))
        return 0
    try:
        os.kill(pid, signal.SIGTERM)
        print(json.dumps({'stopped': True, 'pid': pid}, ensure_ascii=False, indent=2))
    except ProcessLookupError:
        print(json.dumps({'stopped': False, 'reason': 'process not found', 'pid': pid}, ensure_ascii=False, indent=2))
    return 0


def main():
    parser = argparse.ArgumentParser(description='Minimal DLNA/UPnP audio MediaServer for local folders.')
    sub = parser.add_subparsers(dest='cmd', required=True)

    p = sub.add_parser('scan')
    p.add_argument('--root', action='append', required=True, help='Music directory to include. Repeat for multiple roots.')
    p.add_argument('--limit', type=int, default=20)
    p.set_defaults(func=cmd_scan)

    p = sub.add_parser('serve')
    p.add_argument('--root', action='append', required=True, help='Music directory to include. Repeat for multiple roots.')
    p.add_argument('--host', help='LAN IP to bind to; auto-detected by default')
    p.add_argument('--port', type=int, default=8200)
    p.add_argument('--name', help='DLNA friendly name')
    p.set_defaults(func=cmd_serve)

    p = sub.add_parser('status')
    p.set_defaults(func=cmd_status)

    p = sub.add_parser('stop')
    p.set_defaults(func=cmd_stop)

    args = parser.parse_args()
    return args.func(args)


if __name__ == '__main__':
    raise SystemExit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)