Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions airplay/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
"""
AirPlay receiver module for Checkin Cast.

This module provides AirPlay server capabilities using uxplay,
with ZMQ integration to coordinate with the viewer for pausing
the playlist during AirPlay sessions.
"""

__author__ = 'Checkin'
__copyright__ = 'Copyright 2024, Checkin'
__license__ = 'Dual License: GPLv2 and Commercial License'
227 changes: 227 additions & 0 deletions airplay/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
"""
AirPlay server wrapper that monitors uxplay and publishes session events via ZMQ.
"""

import logging
import os
import re
import signal
import subprocess
import sys
import threading
from time import sleep

import zmq

logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('airplay')

# AirPlay session states
STATE_IDLE = 'idle'
STATE_CONNECTED = 'connected'
STATE_STREAMING = 'streaming'


class AirPlayServer:
"""
Wrapper around uxplay that monitors its output and publishes
session state changes via ZMQ.
"""

def __init__(self):
self.device_name = os.getenv('AIRPLAY_NAME', 'Checkin Cast')
self.zmq_server_url = os.getenv(
'ZMQ_SERVER_URL', 'tcp://anthias-server:10001'
)
self.audio_output = os.getenv('AUDIO_OUTPUT', 'hdmi')
self.resolution = os.getenv('AIRPLAY_RESOLUTION', '1920x1080')
self.framerate = os.getenv('AIRPLAY_FRAMERATE', '30')

self.process = None
self.state = STATE_IDLE
self.running = False
self.client_name = None

# ZMQ publisher for session events
self.context = zmq.Context()
self.publisher = self.context.socket(zmq.PUB)
self.publisher.connect(self.zmq_server_url.replace(':10001', ':10002'))
sleep(0.5) # Allow ZMQ to establish connection

# Also create a push socket for direct viewer communication
self.push_socket = self.context.socket(zmq.PUSH)
self.push_socket.setsockopt(zmq.LINGER, 0)
self.push_socket.connect('tcp://anthias-server:5559')
sleep(0.5)

def _build_command(self):
"""Build the uxplay command with appropriate arguments."""
width, height = self.resolution.split('x')

cmd = [
'uxplay',
'-n', self.device_name,
'-s', f'{width}x{height}',
'-fps', self.framerate,
'-vs', 'fbdevsink', # Output to framebuffer
'-vd', '1', # Vsync
]

# Audio output configuration
if self.audio_output == 'hdmi':
cmd.extend(['-as', 'alsasink device=hw:0,0'])
elif self.audio_output == 'headphones':
cmd.extend(['-as', 'alsasink device=hw:1,0'])
else:
cmd.extend(['-as', 'alsasink'])

return cmd

def _publish_state(self, state, client_name=None):
"""Publish state change via ZMQ."""
self.state = state
self.client_name = client_name

message = {
'type': 'airplay_state',
'state': state,
'client_name': client_name,
}

try:
# Publish to subscriber (for websocket server)
self.publisher.send_json(message)
logger.info(f'Published state: {state}, client: {client_name}')

# Also push directly for viewer
self.push_socket.send_json(message, flags=zmq.NOBLOCK)
except zmq.ZMQError as e:
logger.error(f'Failed to publish state: {e}')

def _monitor_output(self):
"""Monitor uxplay stdout/stderr for session events."""
# Patterns to detect session state
connect_pattern = re.compile(r'Connection from .* \((.+)\)')
stream_start_pattern = re.compile(r'Starting video stream')
stream_stop_pattern = re.compile(r'Video stream stopped|Connection closed')

while self.running and self.process:
line = self.process.stderr.readline()
if not line:
if self.process.poll() is not None:
break
continue

line = line.decode('utf-8', errors='replace').strip()
logger.debug(f'uxplay: {line}')

# Check for connection
match = connect_pattern.search(line)
if match:
client = match.group(1)
self._publish_state(STATE_CONNECTED, client)
continue

# Check for stream start
if stream_start_pattern.search(line):
self._publish_state(STATE_STREAMING, self.client_name)
continue

# Check for stream stop / disconnect
if stream_stop_pattern.search(line):
self._publish_state(STATE_IDLE, None)

def start(self):
"""Start the AirPlay server."""
if self.running:
logger.warning('AirPlay server already running')
return

logger.info(f'Starting AirPlay server as "{self.device_name}"')
self.running = True

cmd = self._build_command()
logger.info(f'Command: {" ".join(cmd)}')

try:
self.process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
preexec_fn=os.setsid
)

# Start output monitor thread
monitor_thread = threading.Thread(
target=self._monitor_output,
daemon=True
)
monitor_thread.start()

logger.info(f'AirPlay server started (PID: {self.process.pid})')
self._publish_state(STATE_IDLE)

# Wait for process to complete
self.process.wait()

except Exception as e:
logger.error(f'Failed to start AirPlay server: {e}')
self.running = False
raise
finally:
self.running = False
self._publish_state(STATE_IDLE)

def stop(self):
"""Stop the AirPlay server."""
if not self.running or not self.process:
return

logger.info('Stopping AirPlay server')
self.running = False

try:
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
except ProcessLookupError:
pass

self._publish_state(STATE_IDLE)
logger.info('AirPlay server stopped')

def cleanup(self):
"""Clean up ZMQ resources."""
self.stop()
self.publisher.close()
self.push_socket.close()
self.context.term()


def main():
"""Main entry point for the AirPlay server."""
server = AirPlayServer()

def signal_handler(signum, frame):
logger.info(f'Received signal {signum}, shutting down...')
server.cleanup()
sys.exit(0)

signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)

while True:
try:
server.start()
except Exception as e:
logger.error(f'AirPlay server error: {e}')
sleep(5) # Wait before retry


if __name__ == '__main__':
main()
6 changes: 3 additions & 3 deletions ansible/roles/screenly/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
- name: Install pip dependencies
ansible.builtin.pip:
executable: "/home/{{ lookup('env', 'USER') }}/installer_venv/bin/pip"
requirements: "/home/{{ lookup('env', 'USER') }}/screenly/requirements/requirements.host.txt"
requirements: "/home/{{ lookup('env', 'USER') }}/CheckinSignage/requirements/requirements.host.txt"
extra_args: "--no-cache-dir --upgrade"
when: ansible_distribution_major_version | int >= 12

Expand Down Expand Up @@ -69,8 +69,8 @@

- name: Download upgrade script from github repository
ansible.builtin.get_url:
url: https://raw.githubusercontent.com/Screenly/Anthias/master/bin/install.sh
dest: /usr/local/sbin/upgrade_anthias.sh
url: https://raw.githubusercontent.com/storbukas/CheckinSignage/feature/checkin-cast-airplay/bin/install.sh
dest: /usr/local/sbin/upgrade_checkinsignage.sh
mode: "0700"
owner: root
group: root
Expand Down
18 changes: 13 additions & 5 deletions ansible/roles/system/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,16 +257,24 @@
- docker-compose
state: absent

- name: Ensure keyrings directory exists
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"

- name: Add Docker apt key (x86)
ansible.builtin.apt_key:
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
state: present
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
when: ansible_architecture == "x86_64"

- name: Add Docker apt key (Raspberry Pi)
ansible.builtin.apt_key:
ansible.builtin.get_url:
url: https://download.docker.com/linux/raspbian/gpg
state: present
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
when: |
ansible_architecture == "aarch64" or
ansible_architecture == "armv7l" or
Expand Down Expand Up @@ -306,7 +314,7 @@
ansible.builtin.lineinfile:
path: /etc/apt/sources.list.d/docker.list
create: true
line: "deb [arch={{ architecture }}] https://download.docker.com/linux/debian {{ debian_name.stdout }} stable"
line: "deb [arch={{ architecture }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian {{ debian_name.stdout }} stable"
state: present
owner: root
group: root
Expand Down
6 changes: 6 additions & 0 deletions api/urls/v2.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import path

from api.views.v2 import (
AirPlayViewV2,
AssetContentViewV2,
AssetListViewV2,
AssetsControlViewV2,
Expand Down Expand Up @@ -60,4 +61,9 @@ def get_url_patterns():
IntegrationsViewV2.as_view(),
name='integrations_v2',
),
path(
'v2/airplay',
AirPlayViewV2.as_view(),
name='airplay_v2',
),
]
65 changes: 65 additions & 0 deletions api/views/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,3 +522,68 @@ def get(self, request):
serializer = self.serializer_class(data=data)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)


class AirPlayViewV2(APIView):
"""AirPlay status and control endpoint."""

@extend_schema(
summary='Get AirPlay status',
responses={
200: {
'type': 'object',
'properties': {
'enabled': {'type': 'boolean'},
'name': {'type': 'string'},
'state': {'type': 'string'},
'client_name': {'type': 'string', 'nullable': True},
},
}
},
)
@authorized
def get(self, request):
airplay_state = r.get('airplay_state')
airplay_client = r.get('airplay_client')

return Response({
'enabled': settings.get('airplay_enabled', True),
'name': settings.get('airplay_name', 'Checkin Cast'),
'state': airplay_state.decode() if airplay_state else 'unknown',
'client_name': (
airplay_client.decode() if airplay_client else None
),
})

@extend_schema(
summary='Update AirPlay settings',
request={
'type': 'object',
'properties': {
'enabled': {'type': 'boolean'},
'name': {'type': 'string'},
},
},
responses={
200: {
'type': 'object',
'properties': {'message': {'type': 'string'}},
},
},
)
@authorized
def patch(self, request):
data = request.data

if 'enabled' in data:
settings['airplay_enabled'] = data['enabled']
if 'name' in data:
settings['airplay_name'] = data['name']

settings.save()

# Notify viewer to reload settings
publisher = ZmqPublisher.get_instance()
publisher.send_to_viewer('reload')

return Response({'message': 'AirPlay settings updated successfully.'})
Loading