Source Drivers¶
A source driver lets a module add a kind of live source that ffmpeg cannot read, for example DASH with DRM, and run its own engine for it. The engine takes ffmpeg's place for those sources only. The stream stays an ordinary XC_VM stream: the same stream id, categories, bouquets, monitoring, Start/Stop/Restart, connections, archive and thumbnails.
A stream can mix both kinds. With the primary on your engine and a backup on plain HTTP, the stream fails over between the two with no extra code.
Status
The driver interface, registration, producer selection, process recognition, source checks and save-time validation are in core, and a module tab on the stream form and a module import kind are available (Stream form tabs, Import kinds). HLS renditions are still planned; see Current limitations.
For the module basics (layout, manifest, module class) see Module Authoring; for other hooks see Module Extension Points.
How it works¶
stream_source: ["acmedash://prov1/demo-001", "http://backup.example/live.ts"]
│ │
SourceDriverRegistry::for(url) core: ffmpeg or native remux
│ │
driver->buildArgv($ctx) buildLive() / buildNativeLive()
│ │
└───────────── sources[] ─────────┘
│
the supervisor (xc_fanout) or the PHP monitor
runs one entry and fails over to the next on exit
- Your module declares a driver class in
module.json. - The driver claims one or more URL schemes (
acmedash://…). - When a stream starts, core looks at each source URL. For a URL with your scheme, it
asks the driver whether the source is reachable (
available()) and for the engine's command line (buildArgv()). - Core launches that command exactly like an ffmpeg producer, supervises it, restarts it, and serves its output.
| Term | Meaning |
|---|---|
| Producer | The process that produces a live stream's bytes: ffmpeg, xc_fanout remux, the proxy, the LLOD/delay/loopback workers, or your engine. |
| Source driver | Your class: it claims URL schemes and builds the producer command for them. |
| Supervisor | The xc_fanout daemon, which starts, watches, restarts and adopts producers. When supervision is off, the PHP monitor (console.php monitor) does that job. |
Where the real stream URL comes from¶
Core never knows the upstream URL of a driver source. It stores the URL the operator
entered in the stream's source list, such as acmedash://prov1/demo-001, and hands that
string to your driver unchanged:
- in
available($streamId, $url), as$url; - in
buildArgv($ctx), as$ctx['url'].
Turning it into something your engine can fetch is entirely your module's job. Two patterns work.
Pattern A: an id your module resolves¶
The URL names a channel, not a location: acmedash://<provider>/<channel-id>. The real
manifest URL, headers and keys are looked up when the stream starts, not stored in
core:
operator / import → stream_source = acmedash://prov1/demo-001
stream start → core calls buildArgv(['url' => 'acmedash://prov1/demo-001', …])
your engine → runs the provider script: manifest id=demo-001
→ gets manifest_url, manifest_headers, media_headers
→ runs the provider script: cdm id=demo-001 (keys, if needed)
→ starts fetching; re-signs the URL when it expires
Use this pattern when the upstream URL is short-lived, signed, or needs keys. Nothing
secret or expiring ends up in streams.stream_source, in core logs or in ps. Keep the
provider definitions (which script, which settings) in your module's own tables, keyed
by the <provider> part of the URL.
Pattern B: the upstream URL wrapped in your scheme¶
The URL carries the location itself, and only the scheme changes:
acmedash://cdn.example.com/live/ch1/manifest.mpd. Your driver maps it back:
Use this pattern when the operator already has a stable, public manifest URL and only
needs your engine to read it. Anything after :// is yours, including the query string,
so the upstream URL survives intact.
How the URL gets into a stream¶
- By hand: the operator types it as a source on the normal Add/Edit Stream page. Core accepts any scheme there.
- By import: register an import kind that lists your provider's channels with your URLs. The admin picks it on the Import & Review page and imports the channels as ordinary streams.
- As a backup: put your URL anywhere in the source list. Primary/backup order works the same as for core sources.
Quick start¶
This walkthrough builds a module named acme-dash with the scheme acmedash://.
Replace both with your own names.
1. Layout¶
src/Modules/acme-dash_9f1c0/
├── module.json
├── AcmeDashModule.php # the module class
├── AcmeDashDriver.php # the source driver
├── bin/
│ └── xcvm-acmedash # your engine, executable (chmod 755)
└── migrations/
├── 1.0.0.up.sql
└── 1.0.0.down.sql
Ship the engine inside the module directory, so uninstalling the module removes it.
Do not install it under /home/xc_vm/bin/, which belongs to core.
2. module.json¶
{
"name": "acme-dash",
"hash_id": "9f1c0b7e4d2a6538c1e0a4b7d6f39e21",
"description": "DASH sources through the Acme engine",
"version": "1.0.0",
"requires_core": ">=2.5",
"environment": "main",
"source_drivers": ["XcVm\\Module\\AcmeDash\\AcmeDashDriver"],
"start_timeout": 60
}
| Key | Meaning |
|---|---|
source_drivers |
Fully-qualified class names implementing XcVm\Core\Module\SourceDriverInterface. A module may declare several. |
start_timeout |
Optional, in seconds. How long core waits for your first playlist before it counts the start as failed. Leave it out for the core default (20–30 s). Set it when a cold start includes key acquisition. |
environment |
Must be main. Your engine runs on the main server only; see Main server only. |
3. The driver class¶
<?php
namespace XcVm\Module\AcmeDash;
use XcVm\Core\Module\SourceDriverInterface;
use XcVm\Infrastructure\Database\DatabaseAware;
final class AcmeDashDriver implements SourceDriverInterface {
use DatabaseAware;
public function schemes(): array {
return ['acmedash'];
}
public function binary(): string {
return 'xcvm-acmedash';
}
public function buildArgv(array $ctx): array {
$argv = [
__DIR__ . '/bin/xcvm-acmedash',
'--source', $ctx['url'],
'--config', $this->writeConfig($ctx),
'--playlist', $ctx['hls']['dir'] . $ctx['hls']['playlist'],
'--segment', $ctx['hls']['dir'] . $ctx['hls']['segment_pattern'],
'--hls-time', (string) $ctx['hls']['seg_time'],
'--hls-list-size', (string) $ctx['hls']['list_size'],
'--hls-delete-threshold', (string) $ctx['hls']['delete_threshold'],
'--progress', $ctx['progress_path'],
];
if ($ctx['ingest'] !== null) {
array_push($argv, '--ingest', $ctx['ingest']);
}
return $argv;
}
public function available(int $streamId, string $url): bool {
// Cheap and local: is the provider known and enabled? No manifest fetch here.
$provider = (string) parse_url($url, PHP_URL_HOST);
return (bool) self::db()->fetchValue('SELECT `enabled` FROM `acmedash_providers` WHERE `name` = ?', $provider);
}
/** Network settings and credentials go to a private file, never into argv. */
private function writeConfig(array $ctx): string {
$dir = TMP_PATH . 'acmedash/';
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
$path = $dir . intval($ctx['stream_id']) . '.json';
file_put_contents($path, json_encode(['fetch' => $ctx['fetch']]));
chmod($path, 0600);
return $path;
}
}
What core expects of this class:
- No constructor arguments and no container services. Core loads drivers without booting modules, because a viewer's request can start a stream from the lightweight streaming entry point.
- Database access only through
DatabaseAware+self::db(). schemes()andbinary()must be instant: no network and no subprocesses.available()must answer within a few seconds. Core calls it before a start and before switching to this source, in place of running ffprobe on the URL.- Exceptions are safe. If any method throws, core skips that source and writes the message to the stream's log.
4. The module class¶
Keep your per-stream settings in your own tables, and clean them up when streams are deleted:
<?php
namespace XcVm\Module\AcmeDash;
use XcVm\Core\Events\ListensTo;
use XcVm\Core\Events\Stream\StreamsDeletedEvent;
use XcVm\Core\Module\BaseModule;
use XcVm\Infrastructure\Database\DatabaseAware;
class AcmeDashModule extends BaseModule {
use DatabaseAware;
public function getName(): string {
return 'acme-dash';
}
public function getVersion(): string {
return '1.0.0';
}
#[ListensTo(StreamsDeletedEvent::class)]
public function onStreamsDeleted(StreamsDeletedEvent $event): void {
$ids = array_map('intval', $event->streamIds);
if ($ids !== []) {
self::db()->delete('acmedash_streams', '`stream_id` IN (' . implode(',', $ids) . ')');
}
}
}
Never add a foreign key to streams. Saving a stream rewrites its row
(REPLACE INTO), so an ON DELETE CASCADE would wipe your data on every edit.
5. The engine¶
Your engine is an ordinary executable that follows the producer contract. The short version:
- It runs in the foreground, one process per stream.
- It writes HLS to disk at the paths it is given.
- When
--ingestis given, it also writes MPEG-TS to that unix socket. - It appends progress blocks.
- It survives
SIGKILLwithout leaving children behind.
6. Try it¶
- Install the module and run
console.php status. This installs bundled modules and runs your migrations. - Create a Live Stream with the source
acmedash://prov1/demo-001, no transcoding, delay off. Save and start it. - Watch the stream's log:
/home/xc_vm/content/streams/<id>.errors. Every decision core makes about your source is written there with a[panel]prefix (see Troubleshooting). - Check that the stream list shows the producer badge module. That means core recognises your engine as the running producer.
- Stop the stream and confirm the engine process is gone. Then kill the engine with
kill -9and confirm that the stream restarts.
Reference¶
SourceDriverInterface¶
namespace XcVm\Core\Module;
interface SourceDriverInterface {
/** @return string[] URL schemes this driver owns, lowercase, without "://". */
public function schemes(): array;
/** Basename of the engine executable; stable across upgrades, unique among producers. */
public function binary(): string;
/**
* The producer's argv. Element 0 is the absolute path of binary(); one element
* must contain the full playlist path (hls.dir + hls.playlist). Core escapes
* every element and adds the redirections and pid handling itself.
*
* @return string[]
*/
public function buildArgv(array $ctx): array;
/** Whether the source is reachable now; called instead of ffprobe on the URL. */
public function available(int $streamId, string $url): bool;
}
Core rejects an argv whose element 0 is not an absolute path ending in binary(), or
that does not name the playlist. It then skips the source and logs why.
$ctx¶
| Key | Type | Meaning |
|---|---|---|
stream_id |
int | Stream id. |
url |
string | The source URL your driver claimed, unchanged. |
label |
string | The source's index in the stream's source list (0, 1, …), for logs. |
fetch |
array | The stream's network options: user_agent, proxy, cookie, headers. Only values the operator set; a value equal to the global default is left out, so it never overrides your provider's own. |
hls.dir |
string | Directory for disk HLS, with a trailing slash. |
hls.playlist |
string | Playlist file name: <id>_.m3u8. |
hls.segment_pattern |
string | Segment file name pattern: <id>_%d.ts. |
hls.seg_time |
int | Target segment duration, in seconds. |
hls.list_size |
int | Segments listed in the playlist. |
hls.delete_threshold |
int | Extra segments to keep on disk after they leave the playlist. |
ingest |
?string | Unix socket path of the fanout ingest, or null when fanout is disabled or down. |
progress_path |
string | Where to append progress blocks: <id>_.progress. |
errors_path |
string | The stream's log, <id>.errors. Your stderr is sent there. |
supervised |
bool | true when the xc_fanout supervisor runs the producer, false for the PHP monitor. |
Source URLs¶
- Scheme. Every module picks its own, and one module may own several.
- The format is
[a-z][a-z0-9+.-]*. - Schemes core reads (
http,https,rtmp,rtsp,udp,rtp,srt,file, …) cannot be claimed. - A scheme claimed by two installed modules goes to neither, and both names are logged.
- Prefer a name tied to your module (
acmedash, notdash). - Everything after
://is yours. Core passes it to the driver unchanged. Validate it inavailable()(returnfalse) or inbuildArgv()(throw). - The URL is the source's identity, e.g.
acmedash://<provider>/<channel-id>. Keep identity in the URL rather than in a per-stream table, because each backup source can point at a different channel or provider. - The URL must be printable, with no whitespace or control characters. It is stored
in
stream_sourceand shown in the panel.
Producer contract¶
Process¶
- One foreground process per stream. No daemonising, no double fork: the pid core records must be your engine.
- The command line names the playlist. Stop, kill, the cron's pid fallback and
adoption after a supervisor restart find the producer by
<dir>/<id>_.m3u8in its command line. Core enforces this on the argv. - Stop is
SIGKILL. - The supervisor kills the whole process group; the PHP monitor kills only the pid.
- Keep no state that must be flushed on exit.
- Give every child process (provider scripts, helpers) a parent-death signal, so it
dies with the engine on both paths. In Go:
SysProcAttr{Pdeathsig: syscall.SIGKILL}. - Exit codes. Any exit means the producer ended, and core restarts it or fails over according to the stream's settings. Do not use:
3: reserved (the core remuxer's "unsupported");4: reserved for a future "presentation ended, do not restart". It is treated like any other exit for now.- stderr goes to the stream's log. Write short, readable, one-line messages.
Disk HLS (always)¶
Archive, thumbnails, the monitor, the stats cron and the non-fanout delivery path all
read disk HLS, so write it even when ingest is set.
- Segment numbering. Segments are
<id>_<N>.ts, where N starts at 0 on every launch and has no gaps within a run, as ffmpeg does. - Core deletes
<id>_*on Stop and on an explicit Start. - After a crash, the supervisor relaunches you without cleaning up. Overwrite existing files; never continue a persisted counter.
- Playlist format. It lists basenames, with an
#EXTINFfor every segment, and#EXT-X-MEDIA-SEQUENCEequals the first listed N. - Atomic writes. Write each segment and the playlist atomically: write to a temp file
in the same directory, then
rename(). Segments are plain MPEG-TS; core encrypts on the fly where configured. - Retention. Keep
hls.delete_thresholdextra segments on disk after they leave the playlist. - Timing.
- The first playlist must appear within the start timeout:
start_timeout, or the core default. - After that, the playlist must change at least every
6 × seg_timeseconds, or the stream counts as stalled and is restarted.
Ingest (when ingest is not null)¶
- Connection. Connect to the unix socket and write MPEG-TS, and reconnect if the connection drops. Under the supervisor the socket is always passed, and the supervisor confirms a start by the bytes arriving on it.
- Packet format.
- 188-byte packets, aligned from the first byte.
- PCR on the PMT's PCR PID.
random_access_indicatorset on video keyframes; HLS segments are cut only there.- Audio
stream_type. Use the standard value: AAC0x0F/0x11, MP2/MP30x03/0x04, AC-30x81, E-AC-30x87. Audio signalled as private data (0x06) disables the audio-loss check. - No validation. The supervisor treats any bytes as a live signal and does not check the TS, so its correctness is up to you.
Progress¶
- Append ffmpeg-style blocks to
progress_path:key=valuelines ending withprogress=continue, orprogress=endon a clean finish. - Open the file with
O_APPEND, because core truncates it between reads. - Useful keys are
bitrate(e.g.bitrate=4200.5kbits/s),speedandout_time_us. - Omit
fpsif you don't decode frames. A missing or zerofpsmakes the FPS watchdog skip its check. Do not write a placeholder value.
What core does for you¶
| Situation | Core behaviour |
|---|---|
| Stream start | Asks available(). When it returns true, runs your argv as the producer; otherwise tries the next source. |
| Failover | Moves to the next source in the list when your engine exits. That source can be yours or core's. |
| Priority return | Every 5 minutes, asks available() for higher-priority sources and switches back when one answers. The PHP monitor calls it directly; the supervisor runs console.php source:probe <id> <base64 url>, which calls it. |
| Source checks | The probe button on the stream form, the stream tools check and the on-demand scanner report your source as "Module source available / unavailable" from available(), since it has no codecs to show before your engine runs. |
| Saving a stream | Rejects Direct Source, Direct Proxy and LLOD v2 on a stream with your source, and a load balancer fed directly from the source rather than as a child of main. The same checks apply to imports and mass edits. |
| Forced source switch | Asks available() before switching to your source. |
| Stop / Restart | Kills the process found by pid and playlist, then clears <id>_*. |
| Supervisor restart | Re-adopts your running engine by its playlist path instead of starting a second one. |
| Panel status | The stream list shows the producer as module, with CPU and memory from your process. |
| Codec info | Read from your output by the stats cron. Until then, the panel shows a placeholder (h264/aac). |
Refusals¶
Core refuses your source by itself when the stream uses a setting only ffmpeg can apply, because your engine would drop that setting silently:
| Stream setting | Log message |
|---|---|
| Stream type other than Live Streams | not a live channel (type …) |
| Transcoding, including the logo overlay | transcoding is enabled |
| Custom ffmpeg command | the stream has a custom ffmpeg command |
| Custom track map | the stream maps specific tracks |
| RTMP output | RTMP (FLV) output is enabled |
| External push | the stream is pushed to an external server |
| Forced input audio codec | an input audio codec is forced |
| Delay | a source driver cannot feed a delayed stream |
A refused source is skipped, and core tries the next one. If no source is left, the stream
fails to start. Core never runs ffmpeg on a URL with your scheme. On a node where your
module is not installed, your sources are refused with no source driver for acmedash://
on this node.
Main server only¶
Modules are installed only on the main server, so your engine runs there. To serve a driver stream from a load balancer, assign the LB as a child of the main server. The LB then pulls the stream from main over the loopback like any other child stream, and never sees your URL.
Provider scripts and secrets¶
If your module runs operator-provided scripts, that is code execution as the xc_vm user.
- Execution. Run them as a subprocess with an argv list and no shell, with JSON in and out, a hard timeout, and an absolute cap on stdout and stderr.
- Where scripts live. Keep scripts as files on disk in your module directory. Let the
UI pick from that list and show each script's SHA-256 fingerprint rather than accept
uploads. If you do add uploads, gate them behind their own permission, not
edit_stream. - No secrets in argv. Core records the command line on disk, and
psshows it to every user. Pass credentials and network settings through a0600file, as the example driver does, or fetch them inside the engine.
Media¶
- Tracks. Tracks in your TS (video, audio, DVB subtitles) reach TS and HLS clients unchanged. VLC, Kodi and TiviMate show DVB subtitles; browser players (hls.js, video.js) do not.
- Renditions. Separate audio renditions and WebVTT subtitles need HLS renditions, which core does not support yet. When it does, the producer contract gains a per-rendition output.
Troubleshooting¶
Every decision core makes about your source is written to
/home/xc_vm/content/streams/<id>.errors with a [panel] prefix:
| Message | Meaning |
|---|---|
source #N skipped: <refusal> |
A stream setting refuses drivers; see Refusals. |
source #N skipped: argv[0] must be the absolute path of … |
buildArgv() element 0 is not /…/<binary()>. |
source #N skipped: the command must name …_.m3u8 |
No argv element contains the playlist path. |
source driver reports <url> unavailable |
available() returned false. |
source <url> skipped: <exception message> |
A driver method threw. |
no source driver for …:// on this node |
The module is not installed or not enabled here, or its scheme was contested. |
When the scheme is contested or invalid, look in the PHP error log for
SourceDriverRegistry: lines. They name the modules involved.
If the stream keeps restarting while the engine looks healthy, check these first:
- the playlist path is in the engine's command line;
- the executable's basename equals
binary(); - the playlist changes at least every
6 × seg_timeseconds.
Current limitations¶
These pieces are planned as separate extension points and are not in core yet:
- honouring exit code
4, andSIGTERMwith a grace period beforeSIGKILL; - HLS renditions (multiple audio tracks, WebVTT).
Checklist before you ship¶
- [ ]
binary()equals the engine's executable basename and is unique. - [ ]
buildArgv()element 0 is the absolute engine path; the playlist path is in the argv. - [ ] No credentials or keys in argv.
- [ ] A fresh launch writes
<id>_0.tsfirst, with no gaps; the playlist is replaced atomically. - [ ] With
ingestset, the TS is aligned and has PCR and RAI; the engine reconnects after the socket closes. - [ ] After
kill -9 <engine pid>, no child process is left running. - [ ] Progress blocks are appended and end in
progress=. - [ ]
available()is local and fast. - [ ] Your tables have no foreign key to
streams;StreamsDeletedEventcleans them. - [ ]
environmentismain.