2026-04-14 09:21:10 -04:00
|
|
|
import struct
|
|
|
|
|
import binascii
|
|
|
|
|
import asyncio
|
|
|
|
|
import aiohttp
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Optional
|
|
|
|
|
from ..models import SCTE35Marker
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _encode_33bit_pts(value: int) -> bytes:
|
|
|
|
|
"""Encode 33-bit PTS value into 5 bytes per MPEG-2 spec."""
|
|
|
|
|
byte1 = ((value >> 30) & 0x07) << 1 | 0x01 # top 3 bits + marker
|
|
|
|
|
byte2 = (value >> 22) & 0xFF
|
|
|
|
|
byte3 = ((value >> 15) & 0x7F) << 1 | 0x01 # 7 bits + marker
|
|
|
|
|
byte4 = (value >> 7) & 0xFF
|
|
|
|
|
byte5 = (value & 0x7F) << 1 | 0x01 # bottom 7 bits + marker
|
|
|
|
|
return struct.pack('>BBBBB', byte1, byte2, byte3, byte4, byte5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SCTE35Manager:
|
|
|
|
|
"""Manage SCTE35 splice insert cues for broadcast recording."""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
"""Initialize the SCTE35 manager with empty history."""
|
|
|
|
|
self._marker_history: list[SCTE35Marker] = []
|
|
|
|
|
|
|
|
|
|
def encode_splice_insert(
|
|
|
|
|
self,
|
|
|
|
|
event_id: int,
|
|
|
|
|
duration_seconds: float,
|
|
|
|
|
pts_time: int = 0,
|
|
|
|
|
out_of_network: bool = True,
|
|
|
|
|
splice_immediate: bool = False
|
|
|
|
|
) -> bytes:
|
|
|
|
|
"""
|
|
|
|
|
Encode a SCTE35 splice_insert command as binary bytes.
|
|
|
|
|
Returns the binary SCTE35 cue.
|
|
|
|
|
"""
|
|
|
|
|
if not (0 <= event_id <= 0xFFFFFFFF):
|
|
|
|
|
raise ValueError(f"event_id must be a 32-bit unsigned integer (0-4294967295), got {event_id}")
|
|
|
|
|
if duration_seconds < 0:
|
|
|
|
|
raise ValueError(f"duration_seconds must be non-negative, got {duration_seconds}")
|
|
|
|
|
|
|
|
|
|
flags = 0x00
|
|
|
|
|
if out_of_network:
|
2026-04-14 10:01:57 -04:00
|
|
|
flags |= 0x80
|
|
|
|
|
flags |= 0x40 # program_splice_flag
|
2026-04-14 09:21:10 -04:00
|
|
|
|
|
|
|
|
if duration_seconds > 0:
|
2026-04-14 10:01:57 -04:00
|
|
|
flags |= 0x20 # duration_flag
|
2026-04-14 09:21:10 -04:00
|
|
|
|
|
|
|
|
if splice_immediate:
|
2026-04-14 10:01:57 -04:00
|
|
|
flags |= 0x10
|
|
|
|
|
|
2026-04-14 09:21:10 -04:00
|
|
|
splice_cmd = struct.pack('>I', event_id)
|
2026-04-14 10:01:57 -04:00
|
|
|
splice_cmd += struct.pack('>B', 0x00) # cancel_indicator
|
2026-04-14 09:21:10 -04:00
|
|
|
splice_cmd += struct.pack('>B', flags)
|
|
|
|
|
|
|
|
|
|
if not splice_immediate:
|
|
|
|
|
pts_90k = pts_time * 90000
|
|
|
|
|
pts_bytes = _encode_33bit_pts(pts_90k)
|
|
|
|
|
splice_cmd += pts_bytes
|
|
|
|
|
|
|
|
|
|
if duration_seconds > 0:
|
|
|
|
|
dur_90k = int(duration_seconds * 90000)
|
2026-04-14 10:01:57 -04:00
|
|
|
duration_byte = 0xFE # auto_return=1
|
2026-04-14 09:21:10 -04:00
|
|
|
splice_cmd += struct.pack('>B', duration_byte)
|
|
|
|
|
dur_bytes = _encode_33bit_pts(dur_90k)
|
|
|
|
|
splice_cmd += dur_bytes
|
|
|
|
|
|
|
|
|
|
splice_cmd += struct.pack('>H', 0x0001) # unique_program_id
|
|
|
|
|
splice_cmd += struct.pack('>B', 0x00) # avail_num
|
|
|
|
|
splice_cmd += struct.pack('>B', 0x00) # avails_expected
|
|
|
|
|
|
|
|
|
|
splice_cmd_length = len(splice_cmd)
|
|
|
|
|
|
|
|
|
|
section = struct.pack('>B', 0xFC)
|
|
|
|
|
section += struct.pack('>B', 0x30)
|
|
|
|
|
|
|
|
|
|
payload = struct.pack('>B', 0x00) # protocol_version
|
|
|
|
|
payload += struct.pack('>B', 0x00) # encrypted_packet
|
2026-04-14 10:01:57 -04:00
|
|
|
payload += struct.pack('>5s', b'\x00\x00\x00\x00\x00') # PTS adjustment
|
2026-04-14 09:21:10 -04:00
|
|
|
payload += struct.pack('>B', 0x00) # CW index
|
2026-04-14 10:01:57 -04:00
|
|
|
payload += struct.pack('>H', 0xFFF0) # tier
|
|
|
|
|
payload += struct.pack('>H', splice_cmd_length)
|
2026-04-14 09:21:10 -04:00
|
|
|
payload += splice_cmd
|
|
|
|
|
payload += struct.pack('>H', 0x0000) # descriptor_loop_length
|
|
|
|
|
|
|
|
|
|
section_length = len(payload)
|
|
|
|
|
section += struct.pack('>H', section_length)
|
|
|
|
|
section += payload
|
|
|
|
|
|
|
|
|
|
crc = binascii.crc32(section) & 0xFFFFFFFF
|
|
|
|
|
section += struct.pack('>I', crc)
|
|
|
|
|
|
|
|
|
|
return section
|
|
|
|
|
|
|
|
|
|
async def inject_marker(
|
|
|
|
|
self,
|
|
|
|
|
event_id: int,
|
|
|
|
|
duration_seconds: float,
|
|
|
|
|
webhook_url: Optional[str] = None,
|
|
|
|
|
out_of_network: bool = True,
|
2026-04-14 10:01:57 -04:00
|
|
|
splice_immediate: bool = False,
|
|
|
|
|
port_index: Optional[int] = None,
|
|
|
|
|
srt_destination_url: Optional[str] = None,
|
2026-04-14 09:21:10 -04:00
|
|
|
) -> SCTE35Marker:
|
|
|
|
|
"""
|
|
|
|
|
Create SCTE35 marker, trigger webhook if configured, add to history.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
event_id: Unique identifier for the splice event
|
|
|
|
|
duration_seconds: Duration of the ad break in seconds
|
|
|
|
|
webhook_url: Optional webhook URL to POST marker data to
|
|
|
|
|
out_of_network: Whether this is an out-of-network splice
|
|
|
|
|
splice_immediate: Whether splice should happen immediately
|
2026-04-14 10:01:57 -04:00
|
|
|
port_index: Which port this marker is for (None = global/all)
|
|
|
|
|
srt_destination_url: Target specific SRT destination (None = all)
|
2026-04-14 09:21:10 -04:00
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The created SCTE35Marker object
|
|
|
|
|
"""
|
|
|
|
|
cue_bytes = self.encode_splice_insert(
|
|
|
|
|
event_id=event_id,
|
|
|
|
|
duration_seconds=duration_seconds,
|
|
|
|
|
out_of_network=out_of_network,
|
|
|
|
|
splice_immediate=splice_immediate
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
marker = SCTE35Marker(
|
|
|
|
|
event_id=str(event_id),
|
|
|
|
|
duration_seconds=int(duration_seconds),
|
|
|
|
|
out_of_network=out_of_network,
|
|
|
|
|
splice_immediate=splice_immediate,
|
|
|
|
|
timestamp=datetime.utcnow(),
|
2026-04-14 10:01:57 -04:00
|
|
|
webhook_url=webhook_url,
|
|
|
|
|
port_index=port_index,
|
|
|
|
|
srt_destination_url=srt_destination_url,
|
2026-04-14 09:21:10 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._marker_history.append(marker)
|
|
|
|
|
|
|
|
|
|
if webhook_url:
|
|
|
|
|
try:
|
|
|
|
|
await self._trigger_webhook(marker, cue_bytes)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Webhook trigger failed for event_id {event_id}: {e}")
|
|
|
|
|
|
|
|
|
|
return marker
|
|
|
|
|
|
|
|
|
|
async def _trigger_webhook(self, marker: SCTE35Marker, cue_bytes: bytes) -> None:
|
2026-04-14 10:01:57 -04:00
|
|
|
"""POST JSON payload to webhook_url with marker metadata."""
|
2026-04-14 09:21:10 -04:00
|
|
|
if not marker.webhook_url:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
"event_id": marker.event_id,
|
|
|
|
|
"duration_seconds": marker.duration_seconds,
|
|
|
|
|
"out_of_network": marker.out_of_network,
|
|
|
|
|
"splice_immediate": marker.splice_immediate,
|
|
|
|
|
"timestamp": marker.timestamp.isoformat(),
|
2026-04-14 10:01:57 -04:00
|
|
|
"cue_hex": cue_bytes.hex(),
|
|
|
|
|
"port_index": marker.port_index,
|
|
|
|
|
"srt_destination_url": marker.srt_destination_url,
|
2026-04-14 09:21:10 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
async with session.post(marker.webhook_url, json=payload) as response:
|
|
|
|
|
if response.status != 200:
|
|
|
|
|
logger.warning(f"Webhook returned status {response.status}")
|
|
|
|
|
|
|
|
|
|
def get_marker_history(self) -> list[SCTE35Marker]:
|
2026-04-14 10:01:57 -04:00
|
|
|
"""Return all markers in history as a copy."""
|
2026-04-14 09:21:10 -04:00
|
|
|
return list(self._marker_history)
|
|
|
|
|
|
|
|
|
|
def clear_history(self) -> None:
|
|
|
|
|
"""Clear the marker history."""
|
|
|
|
|
self._marker_history.clear()
|