Skip to content

Module Extension Points

The core extension points a module plugs into: the DI container, cron tasks, versioned migrations, typed events, source drivers, stream form tabs and import kinds. To author a module see Module Authoring; for load/lifecycle see Module Lifecycle.

DI container and service decoration

Services are registered in boot() via ServiceContainer. The container supports:

  • set(id, factory) — lazy singleton via callable, or direct value
  • factory(id, callable) — new instance on every get()
  • decorate(id, callable, priority) — wrap an existing service
// Decorate a service (adds behaviour around the original)
$container->decorate('stream.encoder', function (mixed $inner, ServiceContainer $c): MyEncoder {
    return new MyEncoder($inner, $c->get('settings'));
}, priority: 20);

Decorators are chained by priority (highest wraps outermost). Protected services (db, settings, config, auth) cannot be decorated — any attempt throws RuntimeException.

PSR-11 compliance

ServiceContainer implements ContainerInterface:

public function get(string $id): mixed;  // throws NotFoundException if missing
public function has(string $id): bool;

NotFoundException implements NotFoundExceptionInterface extends ContainerExceptionInterface.


PSR-14 events

Modules subscribe to typed events via getEventSubscribers() or the #[ListensTo] attribute. This is documented in full — dispatch, listener registration, priorities, stoppable events and the built-in event catalogue — on the dedicated Event System page.


Stream Middleware

Deprecated — never ran

Core never ran a stream middleware pipeline: getStreamMiddleware() was never called and the pipeline class is gone. StreamMiddlewareProviderInterface, StreamMiddlewareInterface and StreamContext remain only so an existing module that implements them keeps loading. Do not build on them. To act on streams, use events such as StreamSavedEvent and StreamsDeletedEvent, or a source driver.

Reserved navbar slots

Parent node Module slots
management.service_setup order ≥ 60
logs.system order ≥ 50
profile order 100–980

Logs are a top-level logs tab with the sub-groups logs.connections, logs.streams, logs.system, logs.users — attach an operational module log under logs.system. Attaching a child to a parent key that does not exist silently drops it, so keep these keys in sync with CoreNavbarProvider.


Topbar buttons (TopbarProviderInterface)

The per-page topbar (the primary action button plus the related-tools dropdown above a page) is assembled by XcVm\Core\Util\Topbar. Core pages come from Topbar's own list; a module contributes its buttons through TopbarProviderInterface::registerTopbar(TopbarRegistry $registry), called in the same boot phase as registerNavbar(). BaseModule ships a no-op default, so override it only when you need topbar buttons.

A module can do both of these, in one registerTopbar():

  • Inject buttons into an existing core page — pass that page's key (e.g. movies); your buttons are appended after the core ones.
  • Define a brand-new page of its own — pass a page key core does not know (e.g. watch); the whole topbar for that page comes from your module.
use XcVm\Core\Module\TopbarRegistry;

public function registerTopbar(TopbarRegistry $registry): void
{
    // add($page, $label, $url = null, $permission = null, $attr = null, $order = 100)

    // A page the module owns — first entry becomes the primary button.
    $registry->add('watch', 'Add Folder', 'watch_add', 'folder_watch_add', null, 10);
    $registry->add('watch', 'Settings',   'settings_watch', 'folder_watch_settings', null, 20);
    // JS-only action: no url, carry an onClick via $attr.
    $registry->add('watch', 'Kill Running', null, 'folder_watch_settings', 'onClick="killWatchFolder();"', 40);

    // Inject a button into an existing CORE page.
    $registry->add('movies', 'Watch Folder', 'watch', 'folder_watch', null, 200);
}

Page key is AdminHelpers::getPageName() for the page the button appears on — the same value Topbar matches against.

Entry shape mirrors core's [url, permission, attr]:

Arg Meaning
$url Target page/URL. null for a JS-only action (pair with $attr).
$permission adv sub-permission gating the button. null = always shown.
$attr Raw extra attributes: onClick="…", or a well-known id="…".
$order Sort order among a page's module entries (ascending).

Ordering & the primary button. Within a page, core entries come first, then module entries sorted by $order. Topbar::items() marks the first permission-surviving entry as the primary button; the rest fall into the dropdown. On a page the module owns outright, its lowest-$order entry is the primary.

Permission filtering. Every entry with a non-null $permission is dropped unless Authorization::check('adv', $permission) passes, so buttons never leak to roles that lack the right.

Well-known action ids are wired generically by the shell (footer.php) and gated by core, so a module only needs to emit the id:

id="…" Effect
btn-export-csv / btn-export-json Report export — only rendered on a core-listed log/report page and with the backups permission.
btn-clear-logs Clear-logs modal — the log type comes from core's LOG_TYPES map for the page.

Re-registering the same (page, label) overrides the earlier entry (last-wins), matching NavbarRegistry.


Table data (TableProviderInterface)

A serverSide DataTable posts its id to the admin ./table endpoint (TableController). Core table ids are a hard-coded switch; a module serves its OWN table id through TableProviderInterface::registerTables(TableRegistry $registry) (same boot phase as the others), so the builder lives in the module instead of core. When ./table gets an id that is not a core case, it looks it up in the registry. BaseModule ships a no-op default.

use XcVm\Core\Module\TableRegistry;

public function registerTables(TableRegistry $registry): void
{
    $registry->register('watch_output', [WatchController::class, 'tableWatchOutput']);
}

Handler contract — fn(array $return, int $start, int $limit, bool $isApi): array:

public static function tableWatchOutput(array $rReturn, int $rStart, int $rLimit, bool $rIsAPI): array
{
    global $db;                       // same access the core handlers use
    if (!Authorization::check('adv', 'folder_watch_output')) {
        return $rReturn;              // empty skeleton = no access
    }
    // …read RequestManager params, run COUNT + paged SELECT…
    $rReturn['recordsTotal']    = $rTotal;
    $rReturn['recordsFiltered'] = $rTotal;
    foreach ($rRows as $rRow) {
        // Return CLEAN, KEYED JSON — never HTML. The view renders every cell.
        $rReturn['data'][] = ['id' => (int) $rRow['id'], 'status' => (int) $rRow['status'], /* … */];
    }
    return $rReturn;                  // do NOT echo/exit — TableController encodes it
}

Rules:

  • The handler receives the response skeleton (recordsTotal, recordsFiltered, data) and returns it populated. It must not echo or exit — TableController JSON-encodes the returned array.
  • Return clean, keyed JSON rows — no server-built HTML. Status badges, action buttons and links are rendered client-side by the view (the same convention the core tables follow), which keeps presentation out of the controller and lets permission-dependent cells use flags emitted by the view.
  • For the REST API branch ($isApi), reuse TableController::filterRow($row, $show, $hide) for column include/exclude.
  • The DataTables ajax d.id in the view must match the registered id.

Reseller permissions (PermissionProviderInterface)

The group editor's reseller sub-permission catalogue (XcVm\Core\Reference\PermissionReference) is a core list. A module adds its OWN permission keys through PermissionProviderInterface::registerPermissions(PermissionRegistry $registry) (same boot phase) so the module owns the permissions it gates on, instead of them being hard-coded in core. Keys are merged after the core list.

use XcVm\Core\Module\PermissionRegistry;

public function registerPermissions(PermissionRegistry $registry): void
{
    $registry->add('folder_watch');
    $registry->add('folder_watch_output');
}

Each key shows in the editor with labels from the Translator — add permission_<key> and permission_<key>_text language entries. Gate routes, navbar and topbar items on the key exactly as with a core permission (Authorization::check('adv', 'folder_watch')); enforcement reads the stored group permissions and is unaffected by where the key is declared.

Owning a module table end-to-end. A module log/data table is fully the module's: build its rows via TableProviderInterface (clean JSON), and keep its delete / clear / import bookkeeping in the module too — expose module ->api(...) routes for row actions, and react to a core event (e.g. VodImportedEvent) with #[ListensTo] instead of core writing the table directly. Core must never DELETE/UPDATE/TRUNCATE a module-owned table (it may not exist once the module is uninstalled).


Quick Tools (QuickToolsProviderInterface)

The admin Quick Tools page is a grid of one-shot maintenance buttons; each posts its key to post.php?action=quick_tools, which runs the matching action. Both the button list and the handlers are core. A module adds its OWN tool — button and action — via QuickToolsProviderInterface::registerQuickTools(QuickToolsRegistry $registry).

use XcVm\Core\Module\QuickToolsRegistry;

public function registerQuickTools(QuickToolsRegistry $registry): void
{
    // add($group, $key, $label, $handler)
    $registry->add('logs', 'clear_watch_logs', 'clear_watch_logs', static function (): void {
        WatchService::clearAllLogs();   // do the work; query via global $db
    });
}
  • $group is an existing tab key (streams, lines, logs, general, …) — the tool is appended to it — or a new key, rendered as a new tab with a generic icon and $group as its label key.
  • $label is a translation key for the button.
  • $handler (fn(): void) performs the action and must not echo/exit — post.php emits the standard {result:true} success JSON after it runs.

Cron task

Cron logic (MyCron.php) — business logic only, no CLI wiring.

CronJob wrapper (MyCronJob.php) — implements CommandInterface, uses CronTrait:

class MyCronJob implements CommandInterface {
    use CronTrait;

    public function getName(): string { return 'cron:my_task'; }
    public function getDescription(): string { return 'Cron: my task'; }

    public function execute(array $rArgs): int {
        if (!$this->assertRunAsXcVm()) {
            return 1;
        }

        require INCLUDES_PATH . 'admin.php';
        require_once __DIR__ . '/MyCron.php';

        $this->initCron('XC_VM[MyTask]');
        MyCron::run();

        return 0;
    }
}

Registration in the module:

public function registerCommands(CommandRegistry $registry): void {
    $registry->register(new MyCronJob());
}

Declare the crontab entry by overriding getCronEntries() in the module class:

public function getCronEntries(): array {
    return [
        '*/5 * * * *' => 'cron:my_task',
    ];
}

ModuleLoader::collectCronEntries() aggregates all modules' entries and StartupCommand / StatusCommand write them to the system crontab automatically — no core file changes needed.

Format: key = cron expression, value = console command name registered via registerCommands().


Versioned migrations (MigratableInterface)

Two mechanisms, both additive. The file-based schema described under Module directory structure (database.sql master + database_drop.sql teardown + migrations/<semver>.sql deltas) is the default for plain DDL/seed. MigratableInterface below is the programmatic path for upgrade steps that need PHP logic (data backfills, conditional changes). A module can use either or both; ModuleManager::updateModule() runs the file deltas first, then the callable migrations.

Modules whose upgrades need PHP logic implement MigratableInterface:

namespace XcVm\Module\MyModule;

use BaseModule;
use MigratableInterface;
use ServiceContainer;

class MyModuleModule extends BaseModule implements MigratableInterface {

    public function getMigrations(): array {
        return [
            '1.1.0' => function (): void {
                // runs when upgrading from any version < 1.1.0 to >= 1.1.0
                global $db;
                $db->query("ALTER TABLE xc_my_table ADD COLUMN new_col INT DEFAULT 0");
            },
            '1.2.0' => function (): void {
                // runs when upgrading from < 1.2.0 to >= 1.2.0
            },
        ];
    }
}

ModuleManager::updateModule() reads installed_version from the override store, filters the map to only the entries > fromVersion && <= toVersion, sorts by semver, and runs each callable in its own DB transaction. installModule() records installed_version after a successful install; uninstallModule() clears it.

Key rules:

  • Keys are semver strings ('1.1.0', '2.0.0') — version_compare ordering is used
  • Each migration runs in its own transaction — failure rolls back only that step
  • BaseModule provides a default getMigrations(): array { return []; } so implementing MigratableInterface is optional

Source drivers (SourceDriverInterface)

A module can own a kind of live source that ffmpeg cannot read (for example DASH with DRM) and run its own engine for it in ffmpeg's place. The module declares the driver classes in module.json ("source_drivers": [...]), and each driver claims its own URL scheme. Streams stay ordinary XC_VM streams. See Source Drivers for the interface, the producer contract and a full example.


Stream form tabs (StreamFormRegistry)

A module can add its own tab to the admin Add/Edit Stream page and store what the tab posts in its own tables. Register the tab from boot(). bootAll() resets the registry on every boot, so a tab exists only while its module is loaded.

use XcVm\Core\Container\ServiceContainer;
use XcVm\Core\Events\ListensTo;
use XcVm\Core\Events\Stream\StreamSavedEvent;
use XcVm\Core\Module\StreamFormRegistry;

public function boot(ServiceContainer $container): void {
    StreamFormRegistry::add(
        'acme-dash',                                        // id: [a-z0-9_-]
        'DASH engine',                                      // tab title, already translated
        static fn(?array $stream, string $mode): string =>  // $mode: 'add' | 'edit'
            AcmeDashForm::render($stream === null ? null : (int) $stream['id']),
        'manage_acme_dash',                                 // 'adv' permission, or null
        static fn(array $fields, ?array $stream): ?string =>
            ($fields['provider'] ?? '') === '' ? 'Choose a provider' : null,
    );
}

#[ListensTo(StreamSavedEvent::class)]
public function onStreamSaved(StreamSavedEvent $event): void {
    if (!isset($event->moduleFields['acme-dash'])) {
        return; // an import, an API call or a form without this tab: keep what is stored
    }
    foreach ($event->streamIds as $id) {
        AcmeDashSettings::save($id, $event->moduleFields['acme-dash']);
    }
}
  • Inputs must be named module[<id>][<field>], for example <input name="module[acme-dash][provider]">. Core hands exactly that sub-array back. A module field never reaches a streams column, and core drops fields of tabs the admin may not see.
  • render returns the pane's HTML. $stream is the stream row when editing and null when adding. The tab is not shown on the import form, and mass edit has no module tabs.
  • validate runs before anything is written, and only when the tab's fields were posted: an API save or an import that carries none is never refused by it. Returning a string refuses the save, and the form shows that text as is, so translate it yourself.
  • StreamSavedEvent is dispatched once per save, after every row is written. It carries:
  • streamIds;
  • isNew: false for an edit;
  • source: form (the form or the admin API), import (M3U) or review (Import & Review);
  • moduleFields: tab id => posted fields.

Only act when your id is in moduleFields, otherwise an import or an API save would wipe your settings. A listener that throws is logged and does not fail the save. - Do not add foreign keys to streams. Saving rewrites the row (REPLACE INTO). Clean up on StreamsDeletedEvent instead.


Import kinds (ImportSourceRegistry)

A module can add its own source to the Import & Review page for live streams, next to the built-in M3U file. The admin picks it in the Source selector and fills in the module's inputs. The module lists channels, and they go through the ordinary review and import steps, so every channel becomes an ordinary stream. Register the kind from boot(); bootAll() resets the registry on every boot.

use XcVm\Core\Module\ImportSourceRegistry;

public function boot(ServiceContainer $container): void {
    ImportSourceRegistry::add(
        'acme-dash',                                   // key: [a-z0-9_-]
        'Acme DASH provider',                          // label in the Source picker
        static fn(): string => AcmeDashImport::form(), // inputs: import_source[acme-dash][...]
        static fn(array $fields): array => AcmeDashImport::channels($fields['provider'] ?? ''),
        'manage_acme_dash',                            // 'adv' permission, or null
    );
}

// AcmeDashImport::channels() returns rows like:
// ['url' => 'acmedash://prov1/demo-001', 'title' => 'Demo One',
//  'logo' => 'https://…/logo.png', 'tvg_id' => 'demo.one', 'category' => 'News']
  • Inputs of the kind are named import_source[<key>][<field>]. Its list callable gets exactly that sub-array.
  • Rows need a url. title falls back to the URL, and logo, tvg_id (matched against EPG like an M3U tvg-id) and category are optional.
  • Existing sources. A source URL already in the panel is left out unless the admin ticks Show Potential Duplicates; then it is shown and flagged.
  • Row limit. One review page takes at most ImportSourceRegistry::MAX_ROWS (500) rows; beyond that the page reports too many results.
  • Failures. A list callable that throws shows "no sources" and logs the message, so keep provider calls inside a timeout.
  • After the import, StreamSavedEvent fires with source = 'review' and the new stream ids. The channel identity is in each stream's source URL.