Files
XC_VM/src/Domain/Stream/StreamProcess.php
T
Divarion_D 070d89b3c4 feat(streaming): P2/A2 — tee non-proxy live ffmpeg into the xc_fanout daemon
Bring non-proxy live streams into the daemon (ADR 0003, Phase A). Before
launching a standard live stream's ffmpeg, StreamProcess registers a daemon
ingest (FanoutClient::registerIngest → PUT /ingest/<id>, returns the socket)
and, when the daemon accepted it, builds the HLS output through the `tee`
muxer so one ffmpeg feeds BOTH the existing on-disk HLS and the daemon:

  -map … -f tee "[f=hls:…]<id>_.m3u8|[f=mpegts:onfail=ignore]unix:<ingest.sock>"

- onfail=ignore keeps the on-disk HLS alive if the daemon output drops
  (verified: ffmpeg "continuing with 1/2 slaves"), so a daemon crash never
  takes the stream down. A plain second output would abort ffmpeg entirely.
- Daemon unreachable ⇒ registerIngest returns null ⇒ the original on-disk-only
  HLS output runs, unchanged (the reachability rollback). Loopback/delay
  keep the legacy output.
- Two tee-muxer gotchas handled: tee needs an EXPLICIT -map (plain outputs use
  automatic selection; tee errors "does not contain any stream") — reuse the
  stream's map or -map 0 -copy_unknown; and `individual_header_trailer` is a
  segment-muxer option the hls muxer ignores in flag form but rejects fatally
  inside a tee slave, so it is omitted.
- stopStream unregisters the daemon ingest; FanoutClient::call() now checks the
  socket exists so unregister is a no-op when the daemon isn't deployed.

Box-validated (real panel + ffmpeg, stream 569): one ffmpeg feeds on-disk HLS
(5 segs, m3u8) AND the daemon (/live TS + /hls in-RAM), no errors; panel
playback via the legacy path unaffected (1.69 MB valid TS). PHPStan clean,
441 unit tests pass.

Refs ADR 0003 (Phase A).
2026-08-16 18:16:45 +03:00

1575 lines
71 KiB
PHP

<?php
namespace XcVm\Domain\Stream;
use XcVm\Core\Config\SettingsManager;
use XcVm\Core\Diagnostics\DiagnosticsService;
use XcVm\Core\Http\CurlClient;
use XcVm\Core\Util\StreamUtils;
use XcVm\Streaming\Fanout\FanoutClient;
/**
* StreamProcess — stream process
*
* @package XC_VM_Domain_Stream
* @author Divarion_D <https://github.com/Divarion-D>
* @copyright 2025-2026 Vateron Media
* @link https://github.com/Vateron-Media/XC_VM
* @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html
*/
class StreamProcess {
use \XcVm\Infrastructure\Database\DatabaseAware;
/**
* Write stream action log to file
*
* Migrated from CoreUtilities::streamLog()
*
* @param int $rStreamID
* @param int $rServerID
* @param string $rAction
* @param string $rSource
*/
public static function streamLog($rStreamID, $rServerID, $rAction, $rSource = '') {
if (SettingsManager::getAll()['save_restart_logs'] != 0) {
$rData = array('server_id' => $rServerID, 'stream_id' => $rStreamID, 'action' => $rAction, 'source' => $rSource, 'time' => time());
file_put_contents(LOGS_TMP_PATH . 'stream_log.log', base64_encode(json_encode($rData)) . "\n", FILE_APPEND);
}
}
/**
* Clear cached runtime data for the given stream sources.
*
* @param array $rSources Source identifiers.
* @return void
*/
public static function deleteCache($rSources) {
if (empty($rSources)) {
return;
}
foreach ($rSources as $rSource) {
if (file_exists(CACHE_TMP_PATH . md5($rSource))) {
unlink(CACHE_TMP_PATH . md5($rSource));
}
}
}
/**
* Queue a channel to be started (optionally on a specific server).
*
* @param int $rStreamID Stream id.
* @param int|null $rServerID Target server id, or null for the default.
* @return mixed Queue result.
*/
public static function queueChannel($rStreamID, $rServerID = null) {
$db = self::db();
if (!$rServerID) {
$rServerID = SERVER_ID;
}
$db->query('SELECT `id` FROM `queue` WHERE `stream_id` = ? AND `server_id` = ?;', $rStreamID, $rServerID);
if ($db->num_rows() == 0) {
$db->query("INSERT INTO `queue`(`type`, `stream_id`, `server_id`, `added`) VALUES('channel', ?, ?, ?);", $rStreamID, $rServerID, time());
}
}
/**
* Create the runtime channel entry for a stream.
*
* @param int $rStreamID Stream id.
* @return mixed Creation result.
*/
public static function createChannel($rStreamID) {
shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php created ' . intval($rStreamID) . ' >/dev/null 2>/dev/null &');
return true;
}
/**
* Start the monitor process for a stream.
*
* @param int $rStreamID Stream id.
* @param int $rRestart Restart flag/counter.
* @return mixed Start result.
*/
public static function startMonitor($rStreamID, $rRestart = 0) {
shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php monitor ' . intval($rStreamID) . ' ' . intval($rRestart) . ' >/dev/null 2>/dev/null &');
return true;
}
/**
* Start the proxy process for a stream.
*
* @param int $rStreamID Stream id.
* @return mixed Start result.
*/
public static function startProxy($rStreamID) {
shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php proxy ' . intval($rStreamID) . ' >/dev/null 2>/dev/null &');
return true;
}
/**
* Start thumbnail generation for a stream.
*
* @param int $rStreamID Stream id.
* @return mixed Start result.
*/
public static function startThumbnail($rStreamID) {
shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php thumbnail ' . intval($rStreamID) . ' >/dev/null 2>/dev/null &');
return true;
}
/**
* Insert a cache-invalidation signal for the main server once — skips the
* insert when an identical pending signal already exists. Shared by
* updateStream / updateStreams.
*
* @param array $rCustomData Signal payload (type + id/ids).
* @return void
*/
private static function insertCacheSignalOnce(array $rCustomData) {
$db = self::db();
$rMainID = ConnectionTracker::getMainID();
$rJson = json_encode($rCustomData);
$db->query('SELECT COUNT(*) AS `count` FROM `signals` WHERE `server_id` = ? AND `cache` = 1 AND `custom_data` = ?;', $rMainID, $rJson);
if (($db->get_row()['count'] ?? 0) == 0) {
$db->query('INSERT INTO `signals`(`server_id`, `cache`, `time`, `custom_data`) VALUES(?, 1, ?, ?);', $rMainID, time(), $rJson);
}
}
/**
* Push a stream's configuration update to its server.
*
* @param int $rStreamID Stream id.
* @param bool $rForce Force the update even if unchanged.
* @return mixed Update result.
*/
public static function updateStream($rStreamID, $rForce = false) {
if (!SettingsManager::getAll()['enable_cache']) {
return false;
}
self::insertCacheSignalOnce(array('type' => 'update_stream', 'id' => $rStreamID));
return true;
}
/**
* Push configuration updates for multiple streams.
*
* @param int[] $rStreamIDs Stream ids.
* @return void
*/
public static function updateStreams($rStreamIDs) {
if (!SettingsManager::getAll()['enable_cache']) {
return;
}
self::insertCacheSignalOnce(array('type' => 'update_streams', 'id' => $rStreamIDs));
}
/**
* Build the `-filter_complex` logo/overlay input from transcode attributes.
*
* Attribute 16 carries the logo (val/pos), 17 enables deinterlace (yadif) and
* 9 an optional scale. Attribute 16 is consumed (unset in place) so it is not
* also emitted as a plain ffmpeg flag. Shared by createChannelItem, startMovie
* and startStream, which previously inlined this block verbatim.
*
* @param array $rTranscodeAttributes Transcode attributes (attr 16 unset in place).
* @param bool $rLoopback Loopback streams never overlay a logo.
* @return string `-i <logo> -filter_complex "..."`, or '' when no logo applies.
*/
private static function buildLogoFilterOptions(array &$rTranscodeAttributes, $rLoopback) {
if (!isset($rTranscodeAttributes[16]) || $rLoopback) {
return '';
}
$rAttr = $rTranscodeAttributes;
$rLogoPath = $rAttr[16]['val'];
$rPos = (isset($rAttr[16]['pos']) && $rAttr[16]['pos'] !== '10:10') ? $rAttr[16]['pos'] : '10:main_h-overlay_h-10';
$rChain = array();
$rBase = '[0:v]';
$rVideoFilters = array();
if (isset($rAttr[17])) {
$rVideoFilters[] = 'yadif';
}
if (isset($rAttr[9]['val']) && strlen($rAttr[9]['val']) > 0) {
$rVideoFilters[] = 'scale=' . $rAttr[9]['val'];
}
if (!empty($rVideoFilters)) {
$rChain[] = $rBase . implode(',', $rVideoFilters) . '[bg]';
$rBase = '[bg]';
}
$rChain[] = '[1:v]scale=250:-1[logo]';
$rChain[] = $rBase . '[logo]overlay=' . $rPos;
unset($rTranscodeAttributes[16]);
return '-i ' . escapeshellarg($rLogoPath) . ' -filter_complex "' . implode('; ', $rChain) . '"';
}
/**
* Detect a CUVID hardware decoder for the source when GPU transcoding is on.
*
* Returns `-c:v <codec>_cuvid` for a known GPU-decodable codec, otherwise ''.
* Shared by createChannelItem and startMovie.
*
* @param string $rGpuOptions GPU command from the profile ('' = CPU path).
* @param string $rSourcePath Source URL/path to probe.
* @return string CUVID input-codec flag, or ''.
*/
private static function resolveGpuInputCodec($rGpuOptions, $rSourcePath) {
if (empty($rGpuOptions)) {
return '';
}
$rFFProbeOutput = \XcVm\Streaming\Codec\FFprobeRunner::probeStream($rSourcePath);
if (in_array($rFFProbeOutput['codecs']['video']['codec_name'], array('h264', 'hevc', 'mjpeg', 'mpeg1', 'mpeg2', 'mpeg4', 'vc1', 'vp8', 'vp9'))) {
return '-c:v ' . $rFFProbeOutput['codecs']['video']['codec_name'] . '_cuvid';
}
return '';
}
/**
* Default audio and video codecs to stream copy when the profile left them unset.
* Shared by createChannelItem, startMovie and startStream.
*
* @param array $rTranscodeAttributes Transcode attributes (modified in place).
*/
private static function applyDefaultCopyCodecs(array &$rTranscodeAttributes) {
if (!array_key_exists('-acodec', $rTranscodeAttributes)) {
$rTranscodeAttributes['-acodec'] = 'copy';
}
if (!array_key_exists('-vcodec', $rTranscodeAttributes)) {
$rTranscodeAttributes['-vcodec'] = 'copy';
}
}
/**
* Build the ffmpeg subtitle import + metadata options for a VOD movie.
*
* Imports every configured subtitle as an extra input and maps each one into
* the output. Inputs are 0 = main source and 1..N = subtitles, so metadata
* targets `-map <i+1>`.
*
* Previously the metadata loop was nested inside the import loop and reused
* the same `$i`, which made the import loop run only once (first subtitle) yet
* still emit `-map` for every file — so multi-subtitle movies imported one
* track but mapped non-existent inputs. The two loops are now siblings.
*
* @param string $rSubtitlesJson `movie_subtitles` JSON from the stream row.
* @param array $rServers Server registry (for remote subtitle fetch).
* @return array{0:string,1:string} [$rSubtitlesImport, $rSubtitlesMetadata].
*/
private static function buildSubtitleImport($rSubtitlesJson, $rServers) {
$rSubtitles = json_decode($rSubtitlesJson, true);
$rSubtitlesImport = '';
$rSubtitlesMetadata = '';
if (!empty($rSubtitles) && !empty($rSubtitles['files']) && is_array($rSubtitles['files'])) {
$rCount = count($rSubtitles['files']);
for ($i = 0; $i < $rCount; $i++) {
$rSubtitleFile = escapeshellarg($rSubtitles['files'][$i]);
$rInputCharset = escapeshellarg($rSubtitles['charset'][$i]);
if ($rSubtitles['location'] == SERVER_ID) {
$rSubtitlesImport .= '-sub_charenc ' . $rInputCharset . ' -i ' . $rSubtitleFile . ' ';
} else {
$rSubtitlesImport .= '-sub_charenc ' . $rInputCharset . ' -i "' . $rServers[$rSubtitles['location']]['api_url'] . '&action=getFile&filename=' . urlencode($rSubtitleFile) . '" ';
}
}
for ($i = 0; $i < $rCount; $i++) {
$rSubtitlesMetadata .= '-map ' . ($i + 1) . ' -metadata:s:s:' . $i . ' title=' . escapeshellcmd($rSubtitles['names'][$i]) . ' -metadata:s:s:' . $i . ' language=' . escapeshellcmd($rSubtitles['names'][$i]) . ' ';
}
}
return array($rSubtitlesImport, $rSubtitlesMetadata);
}
/**
* Resolve the ffmpeg `-map` selection for a VOD transcode.
*
* A custom map (when set) wins; otherwise strip subtitles on request, else
* copy everything. Extracted from startMovie.
*
* @param string|null $rCustomMap Admin custom map, or empty for the default.
* @param mixed $rRemoveSubtitles Truthy (== 1) to drop subtitle streams.
* @return string The `-map ...` fragment.
*/
private static function resolveOutputMap($rCustomMap, $rRemoveSubtitles) {
if (!empty($rCustomMap)) {
return escapeshellcmd($rCustomMap) . ' -copy_unknown ';
}
if ($rRemoveSubtitles == 1) {
return '-map 0:a -map 0:v';
}
return '-map 0 -copy_unknown ';
}
/**
* Pick the subtitle codec for a VOD target container. Extracted from startMovie.
*
* @param string $rContainer Target container (mp4/mkv/…).
* @return string ffmpeg subtitle codec: mov_text (mp4), srt (mkv), else copy.
*/
private static function subtitleCodecForContainer($rContainer) {
if ($rContainer == 'mp4') {
return 'mov_text';
}
if ($rContainer == 'mkv') {
return 'srt';
}
return 'copy';
}
/**
* Whether a path recorded against another server ALSO resolves on this
* server's own filesystem — true for shared storage (SAN/NFS/bind-mount)
* that is mounted at an identical path on every node (Main + every LB).
*
* `stream_source` only ever records the server id the path was BROWSED
* from at import time (almost always Main, since only Main runs the admin
* UI) — it says nothing about whether the underlying storage is actually
* server-local or a shared mount. Callers used to treat "not the owning
* server id" as "must fetch over HTTP", which forces an LB to download the
* whole file via ffmpeg even when the exact same path is already mounted
* on it. Checking the real path first lets shared-mount files stay on the
* local/symlink path on every node instead of just the recorded owner.
*
* @param string $rPath Absolute filesystem path recorded in stream_source.
* @return bool
*/
private static function isLocallyMountedPath($rPath) {
if (!is_string($rPath) || $rPath === '') {
return false;
}
$rSharedPrefixes = SettingsManager::get('shared_mount_prefixes', array());
foreach ($rSharedPrefixes as $rPrefix) {
if ($rPrefix !== '' && strncmp($rPath, $rPrefix, strlen($rPrefix)) === 0) {
return file_exists($rPath);
}
}
return false;
}
/**
* Resolve a created-channel source string into [serverId, sourcePath].
*
* A plain string is a local path on this server. An `s:<serverId>:<path>`
* string references a file on another server; when that server is known —
* and the path is not also reachable locally via a shared mount, see
* isLocallyMountedPath() — it is rewritten to its getFile API URL,
* otherwise the raw path is kept. Extracted from createChannelItem. The
* `explode(':', …, 3)` limit keeps colons in the path intact.
*
* @param string $rSource Source string (`path` or `s:<serverId>:<path>`).
* @param mixed $rServers Server registry (array keyed by server id).
* @return array{0:int,1:string} [serverId, sourcePath]
*/
private static function resolveChannelSource($rSource, $rServers) {
if (substr($rSource, 0, 2) == 's:') {
$rSplit = explode(':', $rSource, 3);
$rServerID = intval($rSplit[1]);
$rSourcePath = $rSplit[2];
if ($rServerID != SERVER_ID && !self::isLocallyMountedPath($rSplit[2])) {
if (is_array($rServers) && isset($rServers[$rServerID])) {
$rSourcePath = $rServers[$rServerID]['api_url'] . '&action=getFile&filename=' . urlencode($rSplit[2]);
} else {
$rSourcePath = $rSplit[2];
}
} else {
// Either already local, or a shared-mount path that resolves
// here too, report it as local so the symlink gate upstream
// (createChannelItem's `$rServerID == SERVER_ID` check) fires.
$rSourcePath = $rSplit[2];
$rServerID = SERVER_ID;
}
} else {
$rServerID = SERVER_ID;
$rSourcePath = $rSource;
}
return array($rServerID, $rSourcePath);
}
/**
* Assemble the HLS/mpegts segmenter output arguments for a live stream.
*
* Pure string builder extracted from startStream. The caller still computes
* the conditional $rOptions, $rKeyFrames and $rInitTime and passes them in, so
* the control flow is unchanged.
*
* @param string $rOptions Leading option placeholders ({MAP} {LLOD}).
* @param array $rSegmentSettings seg_time / seg_list_size / seg_delete_threshold.
* @param string $rKeyFrames Extra hls_flags (e.g. '+split_by_time') or ''.
* @param int $rInitTime hls_init_time seconds.
* @param int $rStreamID Stream id (segment/playlist filenames).
* @return string The `-f hls …` output fragment.
*/
private static function buildHlsMpegtsOutput($rOptions, $rSegmentSettings, $rKeyFrames, $rInitTime, $rStreamID) {
return $rOptions . ' -individual_header_trailer 0 -f hls -hls_init_time ' . $rInitTime
. ' -hls_time ' . intval($rSegmentSettings['seg_time'])
. ' -hls_list_size ' . intval($rSegmentSettings['seg_list_size'])
. ' -hls_delete_threshold ' . intval($rSegmentSettings['seg_delete_threshold'])
. ' -hls_flags delete_segments+discont_start+omit_endlist' . $rKeyFrames
. ' -hls_segment_type mpegts -hls_segment_filename "' . STREAMS_PATH . intval($rStreamID) . '_%d.ts" "'
. STREAMS_PATH . intval($rStreamID) . '_.m3u8" ';
}
/**
* Same HLS output as {@see buildHlsMpegtsOutput()} but fanned through the
* `tee` muxer so the stream also feeds the xc_fanout daemon (ADR 0003, A2):
* slave 1 is the existing on-disk HLS, slave 2 pushes mpegts into the daemon's
* ingest socket. `onfail=ignore` on the daemon slave keeps the HLS output
* alive if the daemon is down/restarts (verified: ffmpeg "continuing with 1/2
* slaves"). Used only when the daemon accepted an ingest registration; a plain
* multi-output would abort ffmpeg entirely on a failed daemon output.
*
* The per-muxer flags move from `-flag value` form into the tee slave's
* `:flag=value` form; the leading {MAP}/{LLOD} options stay shared before
* `-f tee`.
*
* @param string $rIngestSock Daemon ingest socket (from FanoutClient::registerIngest()).
* @return string The `-f tee …` output fragment.
*/
private static function buildHlsTeeOutput($rOptions, $rSegmentSettings, $rKeyFrames, $rInitTime, $rStreamID, $rIngestSock) {
$rHls = '[f=hls'
. ':hls_init_time=' . $rInitTime
. ':hls_time=' . intval($rSegmentSettings['seg_time'])
. ':hls_list_size=' . intval($rSegmentSettings['seg_list_size'])
. ':hls_delete_threshold=' . intval($rSegmentSettings['seg_delete_threshold'])
. ':hls_flags=delete_segments+discont_start+omit_endlist' . $rKeyFrames
. ':hls_segment_type=mpegts'
// NB: the flag-form output has `-individual_header_trailer 0`, but that
// is a `segment`-muxer option the `hls` muxer ignores (silent no-op in
// flag form; a FATAL "Unknown option" inside a tee slave). Omitted here.
. ':hls_segment_filename=' . STREAMS_PATH . intval($rStreamID) . '_%d.ts'
. ']' . STREAMS_PATH . intval($rStreamID) . '_.m3u8';
$rDaemon = '[f=mpegts:onfail=ignore:mpegts_flags=+initial_discontinuity]unix:' . $rIngestSock;
return $rOptions . ' -f tee "' . $rHls . '|' . $rDaemon . '"';
}
/**
* Wrap an FLV output target (local RTMP relay or external push URL) with the
* shared `-f flv -flvflags no_duration_filesize` options. Extracted from the
* two identical FLV output lines in startStream.
*
* @param string $rFLVOptions Leading option placeholders ({MAP} {AAC_FILTER}).
* @param string $rTarget The rtmp:// URL or escaped push URL.
* @return string The `… -f flv … <target> ` output fragment.
*/
private static function buildFlvOutput($rFLVOptions, $rTarget) {
return $rFLVOptions . ' -f flv -flvflags no_duration_filesize ' . $rTarget . ' ';
}
/**
* Resolve ffprobe/analysis timing for a live stream start.
*
* On-demand streams use a small (LLOD) or medium analysis window and the
* per-stream probesize; everything else uses the global settings. The read
* timeout is derived from the analysis window plus the configured slack.
* Extracted from startStream.
*
* @param mixed $rOnDemand server_info on_demand flag (== 1 → on-demand).
* @param mixed $rProbesizeOndemand Per-stream on-demand probesize (0 → default).
* @param bool $rLLOD Live-on-demand (shorter analysis window).
* @param array $rSettings Global settings (analyze/probesize/slack).
* @return array{0:int,1:int|string,2:int} [probesize, analyzeDuration, timeout]
*/
private static function resolveProbeSettings($rOnDemand, $rProbesizeOndemand, $rLLOD, $rSettings) {
if ($rOnDemand == 1) {
$rProbesize = intval($rProbesizeOndemand) ?: 1000000;
$rAnalyseDuration = ($rLLOD ? '500000' : '10000000');
} else {
$rAnalyseDuration = abs(intval($rSettings['stream_max_analyze']));
$rProbesize = abs(intval($rSettings['probesize']));
}
$rTimeout = intval($rAnalyseDuration / 1000000) + $rSettings['probe_extra_wait'];
return array($rProbesize, $rAnalyseDuration, $rTimeout);
}
/**
* Failover ordering for a stream's source list.
*
* Unless priority-backup mode is on, and when the last-used source is still
* in the list, rotate every source up to and including it to the end so the
* NEXT untried source leads (already-tried sources become fallbacks).
* Extracted from startStream.
*
* @param array $rSources Ordered source list.
* @param mixed $rPriorityBackup priority_backup setting (== 1 → keep order).
* @param mixed $rCurrentSource Last-used source, or empty.
* @return array The (possibly) reordered source list, re-indexed.
*/
private static function rotateSourcesPastCurrent($rSources, $rPriorityBackup, $rCurrentSource) {
if ($rPriorityBackup == 1 || empty($rCurrentSource)) {
return $rSources;
}
$k = array_search($rCurrentSource, $rSources);
if ($k === false) {
return $rSources;
}
$i = 0;
while ($i <= $k) {
$rTemp = $rSources[$i];
unset($rSources[$i]);
array_push($rSources, $rTemp);
$i++;
}
return array_values($rSources);
}
/**
* Append an extra HTTP header line to a stream's ffmpeg argument list.
*
* If the list already carries a 'headers' entry, the line is appended to it
* (CRLF-separated); otherwise a new 'headers' fetch argument is added. Mirrors
* the X-XC_VM-* header injection repeated in startStream.
*
* @param array $rArguments Argument list (each entry an assoc array).
* @param string $rHeaderLine e.g. 'X-XC_VM-Detect:1'.
* @return array The argument list with the header applied.
*/
private static function appendHeaderArgument($rArguments, $rHeaderLine) {
$rApplied = false;
foreach (array_keys($rArguments) as $rID) {
if ($rArguments[$rID]['argument_key'] == 'headers') {
$rArguments[$rID]['value'] .= "\r\n" . $rHeaderLine;
$rApplied = true;
}
}
if (!$rApplied) {
$rArguments[] = array('value' => $rHeaderLine, 'argument_key' => 'headers', 'argument_cat' => 'fetch', 'argument_wprotocol' => 'http', 'argument_type' => 'text', 'argument_cmd' => "-headers '%s" . "\r\n" . "'");
}
return $rArguments;
}
/**
* Derive the codec metadata persisted for a started live stream from its
* ffprobe output: player compatibility, audio/video codec names and the
* resolution snapped to the nearest standard height. Extracted from startStream.
*
* @param mixed $rFFProbeOutput ffprobe result (array with a 'codecs' entry) or anything else.
* @param mixed $rAllowHevc player_allow_hevc setting, passed to the compatibility check.
* @return array{0:int,1:?string,2:?string,3:mixed} [compatible, audioCodec, videoCodec, resolution]
*/
private static function resolveStreamCodecMeta($rFFProbeOutput, $rAllowHevc) {
$rCompatible = 0;
$rAudioCodec = $rVideoCodec = $rResolution = null;
if (is_array($rFFProbeOutput) && isset($rFFProbeOutput['codecs']) && is_array($rFFProbeOutput['codecs'])) {
$rCompatible = intval(DiagnosticsService::checkCompatibility($rFFProbeOutput, $rAllowHevc));
$rAudioCodec = isset($rFFProbeOutput['codecs']['audio']['codec_name']) ? $rFFProbeOutput['codecs']['audio']['codec_name'] : null;
$rVideoCodec = isset($rFFProbeOutput['codecs']['video']['codec_name']) ? $rFFProbeOutput['codecs']['video']['codec_name'] : null;
$rResolution = isset($rFFProbeOutput['codecs']['video']['height']) ? $rFFProbeOutput['codecs']['video']['height'] : null;
if ($rResolution) {
$rResolution = StreamSorter::getNearest(array(240, 360, 480, 576, 720, 1080, 1440, 2160), $rResolution);
}
}
return array($rCompatible, $rAudioCodec, $rVideoCodec, $rResolution);
}
/**
* The AAC ADTS-to-ASC bitstream filter, required when a copied AAC audio
* stream is muxed into a non-FLV container. Extracted from startStream.
*
* @param mixed $rContainer ffprobe container name.
* @param mixed $rAudioCodec ffprobe audio codec name.
* @param mixed $rACodec resolved output -acodec ('' when absent).
* @return string '-bsf:a aac_adtstoasc' when applicable, otherwise ''.
*/
private static function aacBitstreamFilter($rContainer, $rAudioCodec, $rACodec) {
return (!stristr($rContainer, 'flv') && $rAudioCodec === 'aac' && $rACodec === 'copy') ? '-bsf:a aac_adtstoasc' : '';
}
/**
* Whether the stream's arguments request skipping ffprobe (skip_ffprobe == 1).
* Extracted from startStream.
*
* @param array $rArguments Stream arguments (each an assoc array).
* @return bool
*/
private static function hasSkipFFProbe($rArguments) {
foreach ($rArguments as $rArg) {
if ($rArg['argument_key'] == 'skip_ffprobe' && $rArg['value'] == 1) {
return true;
}
}
return false;
}
/**
* The assumed ffprobe result used when ffprobe is skipped: a plain h264/aac
* mpegts stream. Extracted from startStream.
*
* @return array
*/
private static function skipFFProbeOutput() {
return array(
'codecs' => array(
'video' => array('codec_name' => 'h264', 'codec_type' => 'video', 'height' => 1080),
'audio' => array('codec_name' => 'aac', 'codec_type' => 'audio')
),
'container' => 'mpegts'
);
}
/**
* Resume segment number for a delayed HLS stream, parsed from the existing
* delay playlist lines. Reads the last (or previous) line's "_<n>.ts" index
* and returns n+1; 0 when no index is found. Extracted from startStream.
*
* @param array $rLines Playlist lines (>= 2, newest last).
* @param mixed $rStreamID Stream id; its "<id>_" marks the stream's own segment line.
* @return int Next segment number, or 0.
*/
private static function resolveDelaySegmentStart($rLines, $rStreamID) {
$rLast = $rLines[count($rLines) - 1];
$rPrev = $rLines[count($rLines) - 2];
$rTarget = stristr($rLast, $rStreamID . '_') ? $rLast : $rPrev;
if (preg_match('/_(.*?)\.ts/', $rTarget, $rMatches)) {
return intval($rMatches[1]) + 1;
}
return 0;
}
/**
* Delay sleep seconds for a stream: delay_minutes*60, reduced by ~10s per
* already-produced segment (never below 0). Extracted from startStream.
*
* @param mixed $rDelayMinutes Configured delay in minutes.
* @param int $rSegmentStart Resume segment number (0 = fresh).
* @return int Seconds to sleep.
*/
private static function resolveDelaySleepTime($rDelayMinutes, $rSegmentStart) {
$rSleepTime = $rDelayMinutes * 60;
if ($rSegmentStart > 0) {
$rSleepTime -= ($rSegmentStart - 1) * 10;
if ($rSleepTime <= 0) {
$rSleepTime = 0;
}
}
return $rSleepTime;
}
/**
* Generate and persist a fresh AES-128-CBC key + IV for a stream's HLS
* encryption (the _.key / _.iv sidecar files). Identical setup used by every
* launcher (startStream / startLoopback / startLLOD).
*
* @param int $rStreamID Stream id.
* @return void
*/
private static function writeStreamKeyIv($rStreamID) {
$rKey = openssl_random_pseudo_bytes(16);
file_put_contents(STREAMS_PATH . $rStreamID . '_.key', $rKey);
$rIVSize = openssl_cipher_iv_length('AES-128-CBC');
$rIV = openssl_random_pseudo_bytes($rIVSize);
file_put_contents(STREAMS_PATH . $rStreamID . '_.iv', $rIV);
}
/**
* Clear a stream's leftover segments and stale PID file before a (re)launch.
* Shared preamble of the loopback / LLOD launchers.
*
* @param int $rStreamID Stream id.
* @return void
*/
private static function clearStreamPidSegments($rStreamID) {
shell_exec('rm -f ' . STREAMS_PATH . intval($rStreamID) . '_*.ts');
if (file_exists(STREAMS_PATH . $rStreamID . '_.pid')) {
unlink(STREAMS_PATH . $rStreamID . '_.pid');
}
}
/**
* Read a stream's PID from its sidecar file if present, else fall back to
* the named streams_servers column. Used when stopping a stream to locate
* the feed and monitor processes.
*
* @param int $rStreamID Stream id.
* @param string $rColumn Column to fall back to ('pid' or 'monitor_pid').
* @param string $rSuffix Sidecar suffix ('_.pid' or '_.monitor').
* @return int PID, or 0 if none.
*/
private static function pidFromFileOrColumn($rStreamID, $rColumn, $rSuffix) {
if (file_exists(STREAMS_PATH . $rStreamID . $rSuffix)) {
return intval(file_get_contents(STREAMS_PATH . $rStreamID . $rSuffix));
}
$db = self::db();
$db->query('SELECT `' . $rColumn . '` FROM `streams_servers` WHERE `server_id` = ? AND `stream_id` = ? LIMIT 1;', SERVER_ID, $rStreamID);
$rStreamServer = $db->get_row();
return intval($rStreamServer[$rColumn] ?? 0);
}
/**
* Reset a stream's per-server runtime row to the stopped state — clear pid,
* source, codecs, status and analysis flags. Shared by the stop paths.
*
* @param int $rStreamID Stream id.
* @param bool $rWithMonitor Also clear monitor_pid (full stop vs. movie stop).
* @return void
*/
private static function resetStreamServerRow($rStreamID, $rWithMonitor = false) {
$rMonitor = $rWithMonitor ? ',`monitor_pid` = NULL' : '';
self::db()->query('UPDATE `streams_servers` SET `bitrate` = NULL,`current_source` = NULL,`to_analyze` = 0,`pid` = NULL,`stream_started` = NULL,`stream_info` = NULL,`audio_codec` = NULL,`video_codec` = NULL,`resolution` = NULL,`compatible` = 0,`stream_status` = 0' . $rMonitor . ' WHERE `stream_id` = ? AND `server_id` = ?', $rStreamID, SERVER_ID);
}
/**
* Assemble the live ffmpeg command string from prepared state. PURE: no
* probe/DB/shell/file I/O — the single source of truth for the live command,
* fed from $data instead of loop-local variables. The delay-playlist I/O and
* the segment-start/sleep computation stay in startStream and arrive via
* $data['segmentStart']/['delayActive'].
*
* @param array $data Prepared assembly inputs (see startStream call site).
* @return string The full shell command (ffmpeg + outputs + redirects + pid).
*/
private static function buildLive(array $data): string {
$rStream = $data['stream'];
$rSettings = $data['settings'];
$rServers = $data['servers'];
$rStreamID = $data['streamID'];
$rStreamSource = $data['streamSource'];
$rFetchOptions = $data['fetchOptions'];
$rFFProbeOutput = $data['ffprobe'];
$rProtocol = $data['protocol'];
$rSource = $data['source'];
$rSegmentSettings = $data['segmentSettings'];
$rExternalPush = $data['externalPush'];
$rProbesize = $data['probesize'];
$rAnalyseDuration = $data['analyseDuration'];
$rLLOD = $data['llod'];
$rLoopback = $data['loopback'];
$rSegmentStart = $data['segmentStart'];
$rDelayActive = $data['delayActive'];
$rFFMPEG_CPU = $data['ffmpegCpu'];
$rFFMPEG_GPU = $data['ffmpegGpu'];
$externalPushJson = $rStream['stream_info']['external_push'] ?? '[]';
$rExternalPush = json_decode($externalPushJson, true);
// ffmpeg writes progress reports to this local file; StreamsCronJob tails it.
// (PHP-FPM cannot read ffmpeg's open-ended chunked progress POST, so the HTTP
// /progress endpoint only ever ran at stream end -> speed was stuck at "1x".)
$rProgressFile = STREAMS_PATH . intval($rStreamID) . '_.progress';
// LLOD input resilience: an on-demand HTTP source that drops the
// connection makes ffmpeg exit ("Stream ends prematurely"), which the
// watchdog then restarts — turning a brief upstream hiccup into a
// multi-second re-probe gap and client re-buffering. Reconnecting keeps
// ffmpeg alive across drops instead of dying. Guarded to HTTP(S): these
// options are http-protocol-only and are a fatal "Option not found"
// error on udp/rtmp/file inputs.
$rLLODReconnect = ($rLLOD && !$rLoopback && is_string($rSource) && preg_match('#^https?://#i', $rSource))
? '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 '
: '';
$rLLODInputFlags = ($rLLOD && !$rLoopback ? $rLLODReconnect . '-fflags +discardcorrupt ' : '');
// Command-template defaults: only the non-custom_ffmpeg branch below
// assigns these, yet the {MAP}/{GEN_PTS}/{READ_NATIVE} substitution and
// the delay sleep read them unconditionally. Default them so the
// custom_ffmpeg path (which skips the branch) stays defined.
$rMap = '';
$rGenPTS = '';
$rReadNative = '';
$rSleepTime = 0;
if (empty($rStream['stream_info']['custom_ffmpeg'])) {
if ($rLoopback) {
$rOptions = '{FETCH_OPTIONS}';
} else {
$rOptions = '{GPU} {FETCH_OPTIONS}';
}
if ($rStream['stream_info']['stream_all'] == 1) {
$rMap = '-map 0 -copy_unknown ';
} else {
if (!empty($rStream['stream_info']['custom_map'])) {
$rMap = escapeshellcmd($rStream['stream_info']['custom_map']) . ' -copy_unknown ';
} else {
if ($rStream['stream_info']['type_key'] == 'radio_streams') {
$rMap = '-map 0:a? ';
} else {
$rMap = '';
}
}
}
if (($rStream['stream_info']['gen_timestamps'] == 1 || empty($rProtocol)) && $rStream['stream_info']['type_key'] != 'created_live') {
$rGenPTS = '-fflags +genpts -async 1';
} else {
if (is_array($rFFProbeOutput) && isset($rFFProbeOutput['codecs']['audio']['codec_name']) && in_array($rFFProbeOutput['codecs']['audio']['codec_name'], array('ac3', 'eac3')) && $rSettings['dts_legacy_ffmpeg']) {
$rFFMPEG_CPU = FFMPEG_BIN_40;
}
$rNoFix = ($rFFMPEG_CPU == FFMPEG_BIN_40 ? '-nofix_dts' : '');
$rGenPTS = $rNoFix . ' -start_at_zero -copyts -vsync 0 -correct_ts_overflow 0 -avoid_negative_ts disabled -max_interleave_delta 0';
}
$container = (isset($rFFProbeOutput) && is_array($rFFProbeOutput)) ? ($rFFProbeOutput['container'] ?? null) : null;
if (empty($rStream['server_info']['parent_id']) && (($rStream['stream_info']['read_native'] == 1) || ($container && stristr($container, 'hls') && $rSettings['read_native_hls']) || empty($rProtocol) || ($container && stristr($container, 'mp4')) || ($container && stristr($container, 'matroska')))) {
$rReadNative = '-re';
} else {
$rReadNative = '';
}
if (!$rStream['server_info']['parent_id'] && $rStream['stream_info']['enable_transcode'] == 1 && $rStream['stream_info']['type_key'] != 'created_live') {
if ($rStream['stream_info']['transcode_profile_id'] == -1) {
$rStream['stream_info']['transcode_attributes'] = array_merge(StreamUtils::getArguments($rStream['stream_arguments'], $rProtocol, 'transcode'), json_decode((string) $rStream['stream_info']['transcode_attributes'], true) ?: array());
} else {
$rStream['stream_info']['transcode_attributes'] = json_decode((string) $rStream['stream_info']['profile_options'], true) ?: array();
}
} else {
$rStream['stream_info']['transcode_attributes'] = array();
}
$rFFMPEG = ((isset($rStream['stream_info']['transcode_attributes']['gpu']) ? $rFFMPEG_GPU : $rFFMPEG_CPU)) . ' -y -nostdin -hide_banner -loglevel ' . (($rSettings['ffmpeg_warnings'] ? 'warning' : 'error')) . ' -err_detect ignore_err -thread_queue_size 1024 ' . $rOptions . ' {GEN_PTS} {READ_NATIVE} ' . $rLLODInputFlags . '-probesize ' . $rProbesize . ' -analyzeduration ' . $rAnalyseDuration . ' -progress "' . $rProgressFile . '" {CONCAT} -i {STREAM_SOURCE} {LOGO} -max_muxing_queue_size 1024 ';
self::applyDefaultCopyCodecs($rStream['stream_info']['transcode_attributes']);
if (!array_key_exists('-scodec', $rStream['stream_info']['transcode_attributes'])) {
$rStream['stream_info']['transcode_attributes']['-sn'] = '';
}
} else {
$rStream['stream_info']['transcode_attributes'] = array();
$rFFMPEG = ((stripos($rStream['stream_info']['custom_ffmpeg'], 'nvenc') !== false ? $rFFMPEG_GPU : $rFFMPEG_CPU)) . ' -y -nostdin -hide_banner -loglevel ' . (($rSettings['ffmpeg_warnings'] ? 'warning' : 'error')) . ' -progress "' . $rProgressFile . '" ' . $rStream['stream_info']['custom_ffmpeg'];
}
$rLLODOptions = ($rLLOD && !$rLoopback ? '-tune zerolatency -fflags nobuffer -flags low_delay -strict experimental -threads 0' : '');
$rOutputs = array();
if ($rLoopback) {
$rOptions = '{MAP}';
$rFLVOptions = '{MAP}';
$rMap = '-map 0 -copy_unknown ';
} else {
$rOptions = '{MAP} {LLOD}';
$rFLVOptions = '{MAP} {AAC_FILTER}';
}
$rKeyFrames = ($rSettings['ignore_keyframes'] ? '+split_by_time' : '');
// Fast start: shorten the first segment so players can begin sooner.
// Capped at seg_time so a small seg_time never produces a longer first segment.
$rInitTime = min(2, intval($rSegmentSettings['seg_time']));
// When the xc_fanout daemon accepted an ingest registration (reachable),
// tee the HLS output to it too (ADR 0003, A2). Standard live only — not
// loopback/delay. If the daemon was unreachable ($data['ingestSock'] is
// null) the original on-disk-only HLS output runs, unchanged.
if (!$rLoopback && !$rDelayActive && !empty($data['ingestSock'])) {
// The tee muxer needs an EXPLICIT -map — plain single outputs use
// ffmpeg's automatic stream selection, but tee does not ("Output file
// does not contain any stream" otherwise). Reuse the stream's own map,
// or -map 0 -copy_unknown when it relies on automatic selection.
$rTeeMap = ($rMap !== '' ? $rMap : '-map 0 -copy_unknown ');
$rOutputs['mpegts'][] = self::buildHlsTeeOutput($rTeeMap . '{LLOD}', $rSegmentSettings, $rKeyFrames, $rInitTime, $rStreamID, $data['ingestSock']);
} else {
$rOutputs['mpegts'][] = self::buildHlsMpegtsOutput($rOptions, $rSegmentSettings, $rKeyFrames, $rInitTime, $rStreamID);
}
if ($rStream['stream_info']['rtmp_output'] == 1) {
$rOutputs['flv'][] = self::buildFlvOutput($rFLVOptions, 'rtmp://127.0.0.1:' . intval($rServers[$rStream['server_info']['server_id']]['rtmp_port']) . '/live/' . intval($rStreamID) . '?password=' . urlencode($rSettings['live_streaming_pass']));
}
if (!empty($rExternalPush[SERVER_ID])) {
foreach ($rExternalPush[SERVER_ID] as $rPushURL) {
$rOutputs['flv'][] = self::buildFlvOutput($rFLVOptions, escapeshellarg($rPushURL));
}
}
$rLogoOptions = self::buildLogoFilterOptions($rStream['stream_info']['transcode_attributes'], $rLoopback);
$rGPUOptions = (isset($rStream['stream_info']['transcode_attributes']['gpu']) ? $rStream['stream_info']['transcode_attributes']['gpu']['cmd'] : '');
$rInputCodec = '';
$supportedCodecs = ['h264', 'hevc', 'mjpeg', 'mpeg1', 'mpeg2', 'mpeg4', 'vc1', 'vp8', 'vp9'];
$videoCodec = null;
if (isset($rFFProbeOutput) && is_array($rFFProbeOutput)) {
$videoCodec = $rFFProbeOutput['codecs']['video']['codec_name'] ?? null;
}
if (!empty($rGPUOptions) && in_array($videoCodec, $supportedCodecs)) {
$rInputCodec = '-c:v ' . $rFFProbeOutput['codecs']['video']['codec_name'] . '_cuvid';
}
if (!$rDelayActive) {
foreach ($rOutputs as $rOutputCommands) {
foreach ($rOutputCommands as $rOutputCommand) {
if (isset($rStream['stream_info']['transcode_attributes']['gpu'])) {
$rFFMPEG .= '-gpu ' . intval($rStream['stream_info']['transcode_attributes']['gpu']['device']) . ' ';
}
$rFFMPEG .= implode(' ', StreamUtils::parseTranscode($rStream['stream_info']['transcode_attributes'])) . ' ';
$rFFMPEG .= $rOutputCommand;
}
}
} else {
$rFFMPEG .= implode(' ', StreamUtils::parseTranscode($rStream['stream_info']['transcode_attributes'])) . ' ';
$rFFMPEG .= '{MAP} -individual_header_trailer 0 -f hls -hls_time ' . intval($rSegmentSettings['seg_time']) . ' -hls_list_size ' . intval($rStream['stream_info']['delay_minutes']) * 6 . ' -hls_delete_threshold 4 -start_number ' . $rSegmentStart . ' -hls_flags delete_segments+discont_start+omit_endlist -hls_segment_type mpegts -hls_segment_filename "' . DELAY_PATH . intval($rStreamID) . '_%d.ts" "' . DELAY_PATH . intval($rStreamID) . '_.m3u8" ';
}
$rFFMPEG .= ' >/dev/null 2>>' . STREAMS_PATH . intval($rStreamID) . '.errors & echo $! > ' . STREAMS_PATH . intval($rStreamID) . '_.pid';
$ffprobeContainer = (isset($rFFProbeOutput['container']) && is_string($rFFProbeOutput['container'])) ? $rFFProbeOutput['container'] : '';
$audioCodec = (isset($rFFProbeOutput['codecs']['audio']['codec_name']) && is_array($rFFProbeOutput['codecs']['audio'])) ? $rFFProbeOutput['codecs']['audio']['codec_name'] : '';
$rFFMPEG = str_replace(
['{FETCH_OPTIONS}', '{GEN_PTS}', '{STREAM_SOURCE}', '{MAP}', '{READ_NATIVE}', '{CONCAT}', '{AAC_FILTER}', '{GPU}', '{INPUT_CODEC}', '{LOGO}', '{LLOD}'],
[
empty($rStream['stream_info']['custom_ffmpeg']) ? $rFetchOptions : '',
empty($rStream['stream_info']['custom_ffmpeg']) ? $rGenPTS : '',
escapeshellarg($rStreamSource),
empty($rStream['stream_info']['custom_ffmpeg']) ? $rMap : '',
empty($rStream['stream_info']['custom_ffmpeg']) ? $rReadNative : '',
($rStream['stream_info']['type_key'] == 'created_live' && empty($rStream['server_info']['parent_id']) ? '-safe 0 -f concat' : ''),
self::aacBitstreamFilter($ffprobeContainer, $audioCodec, $rStream['stream_info']['transcode_attributes']['-acodec'] ?? ''),
$rGPUOptions,
$rInputCodec,
$rLogoOptions,
$rLLODOptions
],
$rFFMPEG
);
return $rFFMPEG;
}
public static function createChannelItem($rStreamID, $rSource) {
global $rSettings, $rServers, $rFFMPEG_CPU, $rFFMPEG_GPU;
$db = self::db();
$rStream = array();
$rLoopback = false;
$db->query('SELECT * FROM `streams` t1 INNER JOIN `streams_types` t2 ON t2.type_id = t1.type AND t1.type = 3 LEFT JOIN `profiles` t4 ON t1.transcode_profile_id = t4.profile_id WHERE t1.direct_source = 0 AND t1.id = ?', $rStreamID);
if ($db->num_rows() > 0) {
$rStream['stream_info'] = $db->get_row();
$db->query('SELECT * FROM `streams_servers` WHERE stream_id = ? AND `server_id` = ?', $rStreamID, SERVER_ID);
if ($db->num_rows() > 0) {
$rStream['server_info'] = $db->get_row();
$rMD5 = md5($rSource);
list($rServerID, $rSourcePath) = self::resolveChannelSource($rSource, $rServers);
if ($rServerID == SERVER_ID && intval($rStream['stream_info']['movie_symlink']) == 1) {
$rExtension = pathinfo($rSource)['extension'];
if (strlen($rExtension) == 0) {
$rExtension = 'mp4';
}
$rCommand = 'ln -sfn ' . escapeshellarg($rSourcePath) . ' "' . CREATED_PATH . intval($rStreamID) . '_' . $rMD5 . '.' . escapeshellcmd($rExtension) . '" >/dev/null 2>/dev/null & echo $! > "' . CREATED_PATH . intval($rStreamID) . '_' . $rMD5 . '.pid"';
} else {
$rStream['stream_info']['transcode_attributes'] = json_decode($rStream['stream_info']['profile_options'], true);
if (!is_array($rStream['stream_info']['transcode_attributes'])) {
$rStream['stream_info']['transcode_attributes'] = array();
}
$rLogoOptions = self::buildLogoFilterOptions($rStream['stream_info']['transcode_attributes'], $rLoopback);
$rGPUOptions = (isset($rStream['stream_info']['transcode_attributes']['gpu']) ? $rStream['stream_info']['transcode_attributes']['gpu']['cmd'] : '');
$rInputCodec = self::resolveGpuInputCodec($rGPUOptions, $rSourcePath);
$rCommand = ((isset($rStream['stream_info']['transcode_attributes']['gpu']) ? $rFFMPEG_GPU : $rFFMPEG_CPU)) . ' -y -nostdin -hide_banner -loglevel ' . (($rSettings['ffmpeg_warnings'] ? 'warning' : 'error')) . ' -err_detect ignore_err -progress "' . CREATED_PATH . intval($rStreamID) . '_' . $rMD5 . '.progress" {GPU} -fflags +genpts -async 1 -i {STREAM_SOURCE} {LOGO} ';
self::applyDefaultCopyCodecs($rStream['stream_info']['transcode_attributes']);
if (isset($rStream['stream_info']['transcode_attributes']['gpu'])) {
$rCommand .= '-gpu ' . intval($rStream['stream_info']['transcode_attributes']['gpu']['device']) . ' ';
}
$rCommand .= implode(' ', StreamUtils::parseTranscode($rStream['stream_info']['transcode_attributes'])) . ' ';
$rCommand .= '-strict -2 -mpegts_flags +initial_discontinuity -f mpegts "' . CREATED_PATH . intval($rStreamID) . '_' . $rMD5 . '.ts"';
$rCommand .= ' >/dev/null 2>"' . CREATED_PATH . intval($rStreamID) . '_' . $rMD5 . '.errors" & echo $! > "' . CREATED_PATH . intval($rStreamID) . '_' . $rMD5 . '.pid"';
$rCommand = str_replace(array('{GPU}', '{INPUT_CODEC}', '{LOGO}', '{STREAM_SOURCE}'), array($rGPUOptions, $rInputCodec, $rLogoOptions, escapeshellarg($rSourcePath)), $rCommand);
}
shell_exec($rCommand);
return intval(file_get_contents(CREATED_PATH . intval($rStreamID) . '_' . $rMD5 . '.pid'));
}
return false;
}
return false;
}
/**
* Stop a running channel stream.
*
* @param int $rStreamID Stream id.
* @param bool $rStop Mark the stream as fully stopped (not just restarting).
* @return mixed Stop result.
*/
public static function stopStream($rStreamID, $rStop = false) {
$rMonitor = self::pidFromFileOrColumn($rStreamID, 'monitor_pid', '_.monitor');
if (0 < $rMonitor && \XcVm\Streaming\Health\ProcessChecker::checkPID($rMonitor, array('XC_VM[' . $rStreamID . ']', 'XC_VMProxy[' . $rStreamID . ']')) && is_numeric($rMonitor)) {
posix_kill($rMonitor, 9);
}
$rPID = self::pidFromFileOrColumn($rStreamID, 'pid', '_.pid');
if (0 < $rPID && \XcVm\Streaming\Health\ProcessChecker::checkPID($rPID, array($rStreamID . '_.m3u8', $rStreamID . '_%d.ts', 'LLOD[' . $rStreamID . ']', 'XC_VMProxy[' . $rStreamID . ']', 'Loopback[' . $rStreamID . ']')) && is_numeric($rPID)) {
posix_kill($rPID, 9);
}
if (file_exists(SIGNALS_TMP_PATH . 'queue_' . intval($rStreamID))) {
unlink(SIGNALS_TMP_PATH . 'queue_' . intval($rStreamID));
}
// Drop any xc_fanout daemon ingest listener for this stream (ADR 0003, A2).
// No-op when the daemon isn't reachable.
FanoutClient::unregister(intval($rStreamID));
self::streamLog($rStreamID, SERVER_ID, 'STREAM_STOP');
shell_exec('rm -f ' . STREAMS_PATH . intval($rStreamID) . '_*');
if ($rStop) {
shell_exec('rm -f ' . DELAY_PATH . intval($rStreamID) . '_*');
self::resetStreamServerRow($rStreamID, true);
self::updateStream($rStreamID);
}
}
/**
* Stop a running movie (VOD) stream.
*
* @param int $rStreamID Stream id.
* @param bool $rForce Force stop.
* @return mixed Stop result.
*/
public static function stopMovie($rStreamID, $rForce = false) {
$db = self::db();
shell_exec("kill -9 `ps -ef | grep '/" . intval($rStreamID) . ".' | grep -v grep | awk '{print \$2}'`;");
if ($rForce) {
exec('rm ' . MAIN_HOME . 'content/vod/' . intval($rStreamID) . '.*');
} else {
$db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`, `cache`) VALUES(?, ?, ?, 1);', SERVER_ID, time(), json_encode(array('type' => 'delete_vod', 'id' => $rStreamID)));
}
self::resetStreamServerRow($rStreamID);
self::updateStream($rStreamID);
}
/**
* Queue a movie (VOD) to be started.
*
* @param int $rStreamID Stream id.
* @param int|null $rServerID Target server id, or null for the default.
* @return mixed Queue result.
*/
public static function queueMovie($rStreamID, $rServerID = null) {
$db = self::db();
if (!$rServerID) {
$rServerID = SERVER_ID;
}
$db->query('DELETE FROM `queue` WHERE `stream_id` = ? AND `server_id` = ?;', $rStreamID, $rServerID);
$db->query("INSERT INTO `queue`(`type`, `stream_id`, `server_id`, `added`) VALUES('movie', ?, ?, ?);", $rStreamID, $rServerID, time());
}
/**
* Queue multiple movies to be started.
*
* @param int[] $rStreamIDs Stream ids.
* @param int|null $rServerID Target server id, or null for the default.
* @return void
*/
public static function queueMovies($rStreamIDs, $rServerID = null) {
$db = self::db();
if (!$rServerID) {
$rServerID = SERVER_ID;
}
if (0 < count($rStreamIDs)) {
$db->query('DELETE FROM `queue` WHERE `stream_id` IN (' . implode(',', array_map('intval', $rStreamIDs)) . ') AND `server_id` = ?;', $rServerID);
$rQuery = '';
foreach ($rStreamIDs as $rStreamID) {
if (0 < $rStreamID) {
$rQuery .= "('movie', " . intval($rStreamID) . ', ' . intval($rServerID) . ', ' . time() . '),';
}
}
if (!empty($rQuery)) {
$rQuery = rtrim($rQuery, ',');
$db->query('INSERT INTO `queue`(`type`, `stream_id`, `server_id`, `added`) VALUES ' . $rQuery . ';');
}
}
}
/**
* Refresh movie metadata/processing for the given ids.
*
* @param int[] $rIDs Stream ids.
* @param int $rType Refresh type.
* @return void
*/
public static function refreshMovies($rIDs, $rType = 1) {
$db = self::db();
if (0 < count($rIDs)) {
$db->query('DELETE FROM `watch_refresh` WHERE `type` = ? AND `stream_id` IN (' . implode(',', array_map('intval', $rIDs)) . ');', $rType);
$rQuery = '';
foreach ($rIDs as $rID) {
if (0 < $rID) {
$rQuery .= '(' . intval($rType) . ', ' . intval($rID) . ', 0),';
}
}
if (!empty($rQuery)) {
$rQuery = rtrim($rQuery, ',');
$db->query('INSERT INTO `watch_refresh`(`type`, `stream_id`, `status`) VALUES ' . $rQuery . ';');
}
}
}
/**
* Start a movie (VOD) stream.
*
* @param int $rStreamID Stream id.
* @return mixed Start result.
*/
public static function startMovie($rStreamID) {
global $rSettings, $rServers, $rFFMPEG_CPU, $rFFMPEG_GPU;
$db = self::db();
$rStream = array();
$rLoopback = false;
$db->query('SELECT * FROM `streams` t1 INNER JOIN `streams_types` t2 ON t2.type_id = t1.type AND t2.live = 0 LEFT JOIN `profiles` t4 ON t1.transcode_profile_id = t4.profile_id WHERE t1.direct_source = 0 AND t1.id = ?', $rStreamID);
if ($db->num_rows() > 0) {
$rStream['stream_info'] = $db->get_row();
$db->query('SELECT * FROM `streams_servers` WHERE stream_id = ? AND `server_id` = ?', $rStreamID, SERVER_ID);
if ($db->num_rows() > 0) {
$rStream['server_info'] = $db->get_row();
$db->query('SELECT t1.*, t2.* FROM `streams_options` t1, `streams_arguments` t2 WHERE t1.stream_id = ? AND t1.argument_id = t2.id', $rStreamID);
$rStream['stream_arguments'] = $db->get_rows();
list($rStreamSource) = json_decode($rStream['stream_info']['stream_source'], true);
if (substr($rStreamSource, 0, 2) == 's:') {
$rMovieSource = explode(':', $rStreamSource, 3);
$rMovieServerID = $rMovieSource[1];
if ($rMovieServerID != SERVER_ID && !self::isLocallyMountedPath($rMovieSource[2])) {
$rMoviePath = $rServers[$rMovieServerID]['api_url'] . '&action=getFile&filename=' . urlencode($rMovieSource[2]);
} else {
// Recorded owner is a different server, but the path also
// resolves on THIS server's filesystem (shared mount) use
// it directly and report ourselves as the owner so the
// `ln -s` branch below fires instead of the ffmpeg fallback.
$rMoviePath = $rMovieSource[2];
$rMovieServerID = SERVER_ID;
}
$rProtocol = null;
} else {
if (substr($rStreamSource, 0, 1) == '/') {
$rMovieServerID = SERVER_ID;
$rMoviePath = $rStreamSource;
$rProtocol = null;
} else {
$rProtocol = substr($rStreamSource, 0, strpos($rStreamSource, '://'));
$rMoviePath = str_replace(' ', '%20', $rStreamSource);
$rFetchOptions = implode(' ', StreamUtils::getArguments($rStream['stream_arguments'], $rProtocol, 'fetch'));
}
}
if ((isset($rMovieServerID) && $rMovieServerID == SERVER_ID || file_exists($rMoviePath)) && $rStream['stream_info']['movie_symlink'] == 1) {
$rFFMPEG = 'ln -sfn ' . escapeshellarg($rMoviePath) . ' ' . VOD_PATH . intval($rStreamID) . '.' . escapeshellcmd(pathinfo($rMoviePath)['extension']) . ' >/dev/null 2>/dev/null & echo $! > ' . VOD_PATH . intval($rStreamID) . '_.pid';
} else {
list($rSubtitlesImport, $rSubtitlesMetadata) = self::buildSubtitleImport($rStream['stream_info']['movie_subtitles'], $rServers);
$rReadNative = ($rStream['stream_info']['read_native'] == 1 ? '-re' : '');
if ($rStream['stream_info']['enable_transcode'] == 1) {
if ($rStream['stream_info']['transcode_profile_id'] == -1) {
$rDecoded = json_decode($rStream['stream_info']['transcode_attributes'], true);
$rStream['stream_info']['transcode_attributes'] = array_merge(StreamUtils::getArguments($rStream['stream_arguments'], $rProtocol, 'transcode'), (is_array($rDecoded) ? $rDecoded : array()));
} else {
$rDecoded = json_decode($rStream['stream_info']['profile_options'], true);
$rStream['stream_info']['transcode_attributes'] = (is_array($rDecoded) ? $rDecoded : array());
}
} else {
$rStream['stream_info']['transcode_attributes'] = array();
}
$rLogoOptions = self::buildLogoFilterOptions($rStream['stream_info']['transcode_attributes'], $rLoopback);
$rGPUOptions = (isset($rStream['stream_info']['transcode_attributes']['gpu']) ? $rStream['stream_info']['transcode_attributes']['gpu']['cmd'] : '');
$rInputCodec = self::resolveGpuInputCodec($rGPUOptions, $rMoviePath);
$rFFMPEG = ((isset($rStream['stream_info']['transcode_attributes']['gpu']) ? $rFFMPEG_GPU : $rFFMPEG_CPU)) . ' -y -nostdin -hide_banner -loglevel ' . (($rSettings['ffmpeg_warnings'] ? 'warning' : 'error')) . ' -err_detect ignore_err {GPU} {FETCH_OPTIONS} -fflags +genpts -async 1 {READ_NATIVE} -i {STREAM_SOURCE} {LOGO} ' . $rSubtitlesImport;
$rMap = self::resolveOutputMap($rStream['stream_info']['custom_map'], $rStream['stream_info']['remove_subtitles']);
self::applyDefaultCopyCodecs($rStream['stream_info']['transcode_attributes']);
$rStream['stream_info']['transcode_attributes']['-scodec'] = self::subtitleCodecForContainer($rStream['stream_info']['target_container']);
$rOutputs = array();
$rOutputs[$rStream['stream_info']['target_container']] = '-movflags +faststart -dn ' . $rMap . ' -ignore_unknown ' . $rSubtitlesMetadata . ' ' . VOD_PATH . intval($rStreamID) . '.' . escapeshellcmd($rStream['stream_info']['target_container']);
foreach ($rOutputs as $rOutputCommand) {
$rFFMPEG .= implode(' ', StreamUtils::parseTranscode($rStream['stream_info']['transcode_attributes'])) . ' ';
$rFFMPEG .= $rOutputCommand;
}
$rFFMPEG .= ' >/dev/null 2>' . VOD_PATH . intval($rStreamID) . '.errors & echo $! > ' . VOD_PATH . intval($rStreamID) . '_.pid';
$rFFMPEG = str_replace(array('{GPU}', '{INPUT_CODEC}', '{LOGO}', '{FETCH_OPTIONS}', '{STREAM_SOURCE}', '{READ_NATIVE}'), array($rGPUOptions, $rInputCodec, $rLogoOptions, (empty($rFetchOptions) ? '' : $rFetchOptions), escapeshellarg($rMoviePath), (empty($rStream['stream_info']['custom_ffmpeg']) ? $rReadNative : '')), $rFFMPEG);
}
shell_exec($rFFMPEG);
file_put_contents(VOD_PATH . $rStreamID . '_.ffmpeg', $rFFMPEG);
$rPID = intval(file_get_contents(VOD_PATH . $rStreamID . '_.pid'));
$db->query('UPDATE `streams_servers` SET `to_analyze` = 1,`stream_started` = ?,`stream_status` = 0,`pid` = ? WHERE `stream_id` = ? AND `server_id` = ?', time(), $rPID, $rStreamID, SERVER_ID);
self::updateStream($rStreamID);
return $rPID;
}
return false;
}
return false;
}
/**
* Start a loopback stream.
*
* @param int $rStreamID Stream id.
* @return mixed Start result.
*/
public static function startLoopback($rStreamID) {
global $rSettings, $rServers;
$db = self::db();
self::clearStreamPidSegments($rStreamID);
$rStream = array();
$db->query('SELECT * FROM `streams` WHERE direct_source = 0 AND id = ?', $rStreamID);
if ($db->num_rows() > 0) {
$rStream['stream_info'] = $db->get_row();
$db->query('SELECT * FROM `streams_servers` WHERE stream_id = ? AND `server_id` = ?', $rStreamID, SERVER_ID);
if ($db->num_rows() > 0) {
$rStream['server_info'] = $db->get_row();
if ($rStream['server_info']['parent_id'] != 0) {
shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php loopback ' . intval($rStreamID) . ' ' . intval($rStream['server_info']['parent_id']) . ' >/dev/null 2>/dev/null & echo $! > ' . STREAMS_PATH . intval($rStreamID) . '_.pid');
$rPID = intval(file_get_contents(STREAMS_PATH . $rStreamID . '_.pid'));
$rLoopURL = (!is_null($rServers[SERVER_ID]['private_url_ip']) && !is_null($rServers[$rStream['server_info']['parent_id']]['private_url_ip']) ? $rServers[$rStream['server_info']['parent_id']]['private_url_ip'] : $rServers[$rStream['server_info']['parent_id']]['public_url_ip']);
$rCurrentSource = $rLoopURL . 'admin/live?stream=' . intval($rStreamID) . '&password=' . urlencode($rSettings['live_streaming_pass']) . '&extension=ts';
self::writeStreamKeyIv($rStreamID);
$db->query('UPDATE `streams_servers` SET `delay_available_at` = ?,`to_analyze` = 0,`stream_started` = ?,`stream_info` = ?,`stream_status` = 2,`pid` = ?,`progress_info` = ?,`current_source` = ? WHERE `stream_id` = ? AND `server_id` = ?', null, time(), null, $rPID, json_encode(array()), $rCurrentSource, $rStreamID, SERVER_ID);
self::updateStream($rStreamID);
return array('main_pid' => $rPID, 'stream_source' => $rLoopURL . 'admin/live?stream=' . intval($rStreamID) . '&password=' . urlencode($rSettings['live_streaming_pass']) . '&extension=ts', 'delay_enabled' => false, 'parent_id' => 0, 'delay_start_at' => null, 'playlist' => STREAMS_PATH . $rStreamID . '_.m3u8', 'transcode' => false, 'offset' => 0);
}
return 0;
}
return false;
}
return false;
}
/**
* Start a live-on-demand (LLOD) stream.
*
* @param int $rStreamID Stream id.
* @param array $rStreamInfo Stream metadata.
* @param array $rStreamArguments ffmpeg/stream arguments.
* @param string|null $rForceSource Force a specific source URL.
* @return mixed Start result.
*/
public static function startLLOD($rStreamID, $rStreamInfo, $rStreamArguments, $rForceSource = null) {
$db = self::db();
self::clearStreamPidSegments($rStreamID);
$rSources = ($rForceSource ? array($rForceSource) : json_decode($rStreamInfo['stream_source'], true));
$rArgumentMap = array();
foreach ($rStreamArguments as $rStreamArgument) {
$rArgumentMap[$rStreamArgument['argument_key']] = array('value' => $rStreamArgument['value'], 'argument_default_value' => $rStreamArgument['argument_default_value']);
}
shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php llod ' . intval($rStreamID) . ' "' . base64_encode(json_encode($rSources)) . '" "' . base64_encode(json_encode($rArgumentMap)) . '" >/dev/null 2>/dev/null & echo $! > ' . STREAMS_PATH . intval($rStreamID) . '_.pid');
$rPID = intval(file_get_contents(STREAMS_PATH . $rStreamID . '_.pid'));
self::writeStreamKeyIv($rStreamID);
$db->query('UPDATE `streams_servers` SET `delay_available_at` = ?,`to_analyze` = 0,`stream_started` = ?,`stream_info` = ?,`stream_status` = 2,`pid` = ?,`progress_info` = ?,`current_source` = ? WHERE `stream_id` = ? AND `server_id` = ?', null, time(), null, $rPID, json_encode(array()), $rSources[0], $rStreamID, SERVER_ID);
self::updateStream($rStreamID);
return array('main_pid' => $rPID, 'stream_source' => $rSources[0], 'delay_enabled' => false, 'parent_id' => 0, 'delay_start_at' => null, 'playlist' => STREAMS_PATH . $rStreamID . '_.m3u8', 'transcode' => false, 'offset' => 0);
}
/**
* Start a live stream (main entry point for channel start-up).
*
* Selects a source, builds the ffmpeg command and launches the process.
*
* @param int $rStreamID Stream id.
* @param bool $rFromCache Use cached stream info.
* @param string|null $rForceSource Force a specific source URL.
* @param bool $rLLOD Treat as live-on-demand.
* @param int $rStartPos Start position/offset.
* @return mixed Start result.
*/
public static function startStream($rStreamID, $rFromCache = false, $rForceSource = null, $rLLOD = false, $rStartPos = 0) {
global $rSettings, $rServers, $rFFMPEG_CPU, $rFFMPEG_GPU, $rFFPROBE;
$db = self::db();
$rSegmentSettings = array('seg_time' => intval($rSettings['seg_time']), 'seg_list_size' => intval($rSettings['seg_list_size']), 'seg_delete_threshold' => intval($rSettings['seg_delete_threshold']));
@unlink(STREAMS_PATH . $rStreamID . '_.pid');
$rStream = array();
$db->query('SELECT * FROM `streams` t1 INNER JOIN `streams_types` t2 ON t2.type_id = t1.type AND t2.live = 1 LEFT JOIN `profiles` t4 ON t1.transcode_profile_id = t4.profile_id WHERE t1.direct_source = 0 AND t1.id = ?', $rStreamID);
if ($db->num_rows() > 0) {
$rStream['stream_info'] = $db->get_row();
$db->query('SELECT * FROM `streams_servers` WHERE stream_id = ? AND `server_id` = ?', $rStreamID, SERVER_ID);
if ($db->num_rows() > 0) {
$rStream['server_info'] = $db->get_row();
$db->query('SELECT t1.*, t2.* FROM `streams_options` t1, `streams_arguments` t2 WHERE t1.stream_id = ? AND t1.argument_id = t2.id', $rStreamID);
$rStream['stream_arguments'] = $db->get_rows();
list($rProbesize, $rAnalyseDuration, $rTimeout) = self::resolveProbeSettings($rStream['server_info']['on_demand'], $rStream['stream_info']['probesize_ondemand'], $rLLOD, $rSettings);
$rFFProbee = 'timeout ' . $rTimeout . ' ' . $rFFPROBE . ' {FETCH_OPTIONS} -probesize ' . $rProbesize . ' -analyzeduration ' . $rAnalyseDuration . ' {CONCAT} -i {STREAM_SOURCE} -v quiet -print_format json -show_streams -show_format';
$rFetchOptions = '';
$rLoopback = false;
$rOffset = 0;
if (!$rStream['server_info']['parent_id']) {
if ($rStream['stream_info']['type_key'] == 'created_live') {
$rSources = array(CREATED_PATH . $rStreamID . '_.list');
if ($rStartPos > 0) {
$rCCOutput = array();
$rCCDuration = array();
$rCCInfo = json_decode($rStream['server_info']['cc_info'], true) ?: array();
foreach ($rCCInfo as $rItem) {
$rCCDuration[$rItem['path']] = intval(explode('.', $rItem['seconds'])[0]);
}
$rTimer = 0;
$rValid = true;
foreach (explode("\n", file_get_contents(CREATED_PATH . $rStreamID . '_.list')) as $rItem) {
$rItemParts = explode("file '", $rItem);
if (!isset($rItemParts[1])) {
continue;
}
list($rPath) = explode("'", $rItemParts[1]);
if ($rPath) {
if (!empty($rCCDuration[$rPath])) {
$rDuration = $rCCDuration[$rPath];
if ($rTimer <= $rStartPos && $rStartPos < $rTimer + $rDuration) {
$rOffset = $rTimer;
$rCCOutput[] = $rPath;
} else {
if ($rStartPos < $rTimer + $rDuration) {
$rCCOutput[] = $rPath;
}
}
$rTimer += $rDuration;
} else {
$rValid = false;
}
}
}
if ($rValid) {
$rSources = array(CREATED_PATH . $rStreamID . '_.tlist');
$rTList = '';
foreach ($rCCOutput as $rItem) {
$rTList .= "file '" . $rItem . "'" . "\n";
}
file_put_contents(CREATED_PATH . $rStreamID . '_.tlist', $rTList);
}
}
} else {
$rSources = json_decode($rStream['stream_info']['stream_source'], true);
}
if (count($rSources) > 0) {
if (!empty($rForceSource)) {
$rSources = array($rForceSource);
} else {
$rSources = self::rotateSourcesPastCurrent($rSources, $rSettings['priority_backup'], $rStream['server_info']['current_source']);
}
}
} else {
$rLoopback = true;
if ($rStream['server_info']['on_demand']) {
$rLLOD = true;
}
$rLoopURL = (!is_null($rServers[SERVER_ID]['private_url_ip']) && !is_null($rServers[$rStream['server_info']['parent_id']]['private_url_ip']) ? $rServers[$rStream['server_info']['parent_id']]['private_url_ip'] : $rServers[$rStream['server_info']['parent_id']]['public_url_ip']);
$rSources = array($rLoopURL . 'admin/live?stream=' . intval($rStreamID) . '&password=' . urlencode($rSettings['live_streaming_pass']) . '&extension=ts');
}
if ($rStream['stream_info']['type_key'] == 'created_live' && file_exists(CREATED_PATH . $rStreamID . '_.info')) {
$db->query('UPDATE `streams_servers` SET `cc_info` = ? WHERE `server_id` = ? AND `stream_id` = ?;', file_get_contents(CREATED_PATH . $rStreamID . '_.info'), SERVER_ID, $rStreamID);
}
if (!$rFromCache) {
self::deleteCache($rSources);
}
// Loop-scoped state read again after the loop (final DB update,
// command substitution). Default it so an empty $rSources list can
// never surface an undefined variable downstream.
$rSource = '';
$rRealSource = '';
$rStreamSource = '';
$rProtocol = '';
$rFFProbeOutput = array();
foreach ($rSources as $rSource) {
$rRealSource = $rSource;
$rStreamSource = StreamUtils::parseStreamURL($rSource);
echo 'Checking source: ' . $rSource . "\n";
$rURLInfo = parse_url($rStreamSource);
$rIsXC_VM = ($rLoopback ? true : StreamUtils::detectXC_VM($rStreamSource));
if ($rIsXC_VM && !$rLoopback && $rSettings['send_xc_vm_header']) {
$rStream['stream_arguments'] = self::appendHeaderArgument($rStream['stream_arguments'], 'X-XC_VM-Detect:1');
}
$rProbeArguments = $rStream['stream_arguments'];
if ($rIsXC_VM && $rStream['server_info']['on_demand'] == 1 && $rSettings['request_prebuffer'] == 1) {
$rStream['stream_arguments'] = self::appendHeaderArgument($rStream['stream_arguments'], 'X-XC_VM-Prebuffer:1');
}
$rProbeArguments = self::appendHeaderArgument($rProbeArguments, 'X-XC_VM-Prebuffer:1');
$rProtocol = strtolower(substr($rStreamSource, 0, strpos($rStreamSource, '://')));
$rProbeOptions = implode(' ', StreamUtils::getArguments($rProbeArguments, $rProtocol, 'fetch'));
$rFetchOptions = implode(' ', StreamUtils::getArguments($rStream['stream_arguments'], $rProtocol, 'fetch'));
$rSkipFFProbe = self::hasSkipFFProbe($rStream['stream_arguments']);
if ($rSkipFFProbe) {
$rFFProbeOutput = self::skipFFProbeOutput();
error_log('[XC_VM] Stream ' . $rStreamID . ': FFProbe skipped');
echo 'Got stream information via skip_ffprobe (assumed h264/aac)' . "\n";
if (empty($rSource)) {
$rSource = is_array($rSources) && count($rSources) > 0 ? $rSources[0] : $rStreamSource;
}
break;
}
if ($rFromCache && file_exists(CACHE_TMP_PATH . md5($rSource)) && time() - filemtime(CACHE_TMP_PATH . md5($rSource)) <= 300) {
// Cache key must match the write below and the existence check
// above (both md5($rSource)); reading md5($rStreamSource) here
// fetched a different file, so the cache always missed.
$rFFProbeOutput = igbinary_unserialize(file_get_contents(CACHE_TMP_PATH . md5($rSource)));
if ($rFFProbeOutput && (isset($rFFProbeOutput['streams']) || isset($rFFProbeOutput['codecs']))) {
echo 'Got stream information via cache' . "\n";
break;
}
} else {
if ($rFromCache && file_exists(CACHE_TMP_PATH . md5($rSource))) {
$rFromCache = false;
}
}
if (!($rStream['server_info']['on_demand'] && $rLLOD)) {
if ($rIsXC_VM && $rSettings['api_probe']) {
$rProbeURL = $rURLInfo['scheme'] . '://' . $rURLInfo['host'] . (isset($rURLInfo['port']) ? ':' . $rURLInfo['port'] : '') . '/probe/' . base64_encode($rURLInfo['path'] ?? '');
$rFFProbeOutput = json_decode(CurlClient::getURL($rProbeURL), true);
if ($rFFProbeOutput && isset($rFFProbeOutput['codecs'])) {
echo 'Got stream information via API' . "\n";
break;
}
}
$rProbeCmd = str_replace(array('{FETCH_OPTIONS}', '{CONCAT}', '{STREAM_SOURCE}'), array($rProbeOptions, ($rStream['stream_info']['type_key'] == 'created_live' && !$rStream['server_info']['parent_id'] ? '-safe 0 -f concat' : ''), escapeshellarg($rStreamSource)), $rFFProbee);
$rFFProbeOutput = json_decode(shell_exec($rProbeCmd), true);
if ($rFFProbeOutput && isset($rFFProbeOutput['streams'])) {
echo 'Got stream information via ffprobe' . "\n";
break;
}
}
}
if (!($rStream['server_info']['on_demand'] && $rLLOD)) {
if (!isset($rFFProbeOutput['codecs'])) {
$rFFProbeOutput = \XcVm\Streaming\Codec\FFprobeRunner::parseFFProbe($rFFProbeOutput);
}
if (empty($rFFProbeOutput)) {
$db->query("UPDATE `streams_servers` SET `progress_info` = '',`to_analyze` = 0,`pid` = -1,`stream_status` = 1 WHERE `server_id` = ? AND `stream_id` = ?", SERVER_ID, $rStreamID);
return 0;
}
if (!$rFromCache) {
file_put_contents(CACHE_TMP_PATH . md5($rSource), igbinary_serialize($rFFProbeOutput));
}
}
// Delay-mode playlist bookkeeping (segment start + sleep window). Must run
// before buildLive(), which consumes segmentStart / delayActive.
$rSleepTime = 0;
$rSegmentStart = 0;
$rDelayActive = !(0 >= $rStream['stream_info']['delay_minutes'] || $rStream['server_info']['parent_id']);
if ($rDelayActive) {
$m3u8File = DELAY_PATH . $rStreamID . '_.m3u8';
$oldM3u8File = DELAY_PATH . intval($rStreamID) . '_.m3u8_old';
if (file_exists($m3u8File)) {
$rFile = file($m3u8File, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!is_array($rFile) || count($rFile) < 2) {
return;
}
$rSegmentStart = self::resolveDelaySegmentStart($rFile, $rStreamID);
if (file_exists($oldM3u8File)) {
file_put_contents($oldM3u8File, file_get_contents($oldM3u8File) . file_get_contents($m3u8File));
shell_exec("sed -i '/EXTINF\\|.ts/!d' " . escapeshellarg($oldM3u8File));
} else {
copy($m3u8File, $oldM3u8File);
}
}
$rSleepTime = self::resolveDelaySleepTime($rStream['stream_info']['delay_minutes'], $rSegmentStart);
}
// Assemble the live ffmpeg command (pure). buildLive() does all the
// transcode-attribute resolution and {TEMPLATE} substitution internally, so
// it is fed the raw stream row.
// Register a daemon ingest for standard live streams (ADR 0003,
// A2). The daemon starts listening on the returned socket, then
// buildLive tees the HLS output into it. Null when the daemon is
// unreachable → buildLive emits the on-disk-only HLS (rollback).
$rIngestSock = (!$rLoopback && !$rDelayActive) ? FanoutClient::registerIngest($rStreamID) : null;
$rFFMPEG = self::buildLive(array(
'stream' => $rStream, 'settings' => $rSettings, 'servers' => $rServers,
'streamID' => $rStreamID, 'streamSource' => $rStreamSource,
'fetchOptions' => $rFetchOptions, 'ffprobe' => $rFFProbeOutput,
'protocol' => $rProtocol, 'source' => $rSource,
'segmentSettings' => $rSegmentSettings, 'externalPush' => array(),
'probesize' => $rProbesize, 'analyseDuration' => $rAnalyseDuration,
'llod' => $rLLOD, 'loopback' => $rLoopback,
'segmentStart' => $rSegmentStart, 'delayActive' => $rDelayActive,
'ffmpegCpu' => $rFFMPEG_CPU, 'ffmpegGpu' => $rFFMPEG_GPU,
'ingestSock' => $rIngestSock,
));
shell_exec($rFFMPEG);
file_put_contents(STREAMS_PATH . $rStreamID . '_.ffmpeg', $rFFMPEG);
self::writeStreamKeyIv($rStreamID);
// Wait briefly for PID file to be written, with retry
$rPID = 0;
$rPIDRetries = 0;
while ($rPIDRetries < 10) {
usleep(50000); // 50ms
if (file_exists(STREAMS_PATH . $rStreamID . '_.pid')) {
$rPID = intval(file_get_contents(STREAMS_PATH . $rStreamID . '_.pid'));
if ($rPID > 0) {
break;
}
}
$rPIDRetries++;
}
if ($rStream['stream_info']['tv_archive_server_id'] == SERVER_ID) {
shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php archive ' . intval($rStreamID) . ' >/dev/null 2>/dev/null & echo $!');
}
if ($rStream['stream_info']['vframes_server_id'] == SERVER_ID) {
self::startThumbnail($rStreamID);
}
$rDelayEnabled = 0 < $rStream['stream_info']['delay_minutes'] && !$rStream['server_info']['parent_id'];
$rDelayStartAt = ($rDelayEnabled ? time() + $rSleepTime : 0);
if ($rStream['stream_info']['enable_transcode']) {
$rFFProbeOutput = array();
}
list($rCompatible, $rAudioCodec, $rVideoCodec, $rResolution) = self::resolveStreamCodecMeta($rFFProbeOutput, SettingsManager::getAll()['player_allow_hevc']);
$rFFProbeOutputSafe = isset($rFFProbeOutput) && is_array($rFFProbeOutput) ? $rFFProbeOutput : [];
$db->query('UPDATE `streams_servers` SET `delay_available_at` = ?,`to_analyze` = 0,`stream_started` = ?,`stream_info` = ?,`audio_codec` = ?, `video_codec` = ?, `resolution` = ?,`compatible` = ?,`stream_status` = 2,`pid` = ?,`progress_info` = ?,`current_source` = ? WHERE `stream_id` = ? AND `server_id` = ?', $rDelayStartAt, time(), json_encode($rFFProbeOutputSafe), $rAudioCodec, $rVideoCodec, $rResolution, $rCompatible, $rPID, json_encode(array()), $rSource, $rStreamID, SERVER_ID);
self::updateStream($rStreamID);
$rPlaylist = (!$rDelayEnabled ? STREAMS_PATH . $rStreamID . '_.m3u8' : DELAY_PATH . $rStreamID . '_.m3u8');
return array('main_pid' => $rPID, 'stream_source' => $rRealSource, 'delay_enabled' => $rDelayEnabled, 'parent_id' => $rStream['server_info']['parent_id'], 'delay_start_at' => $rDelayStartAt, 'playlist' => $rPlaylist, 'transcode' => $rStream['stream_info']['enable_transcode'], 'offset' => $rOffset);
} else {
return false;
}
} else {
return false;
}
}
}