Точки расширения модуля¶
Основные точки расширения, к которым подключается модуль: контейнер DI, задачи cron, миграции версий, типизированные события, исходные драйверы, вкладки потоковой формы и виды импорта. Чтобы создать модуль, смотрите Создание модуля; для загрузки/жизненного цикла смотрите Жизненный цикл модуля.
Оформление контейнеров и сервизов DI¶
Сервисы регистрируются в boot() через ServiceContainer. Контейнер поддерживает:
set(id, factory)— отложенный синглтон с помощью вызываемого или прямого значенияfactory(id, callable)— новый экземпляр для каждогоget()decorate(id, callable, priority)— завершение существующей службы
// 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);
Декораторы объединены в цепочки по приоритету (самый высокий и самый внешний). Защищенные сервисы
(db, settings, config, auth) не удается оформить — любая попытка приводит к результату RuntimeException.
Соответствие требованиям стандарта PSR-11¶
ServiceContainer реализует ContainerInterface:
public function get(string $id): mixed; // throws NotFoundException if missing
public function has(string $id): bool;
NotFoundException реализует NotFoundExceptionInterface extends ContainerExceptionInterface.
События PSR-14¶
Модули подписываются на типизированные события с помощью атрибута getEventSubscribers() или #[ListensTo]. Это описано в полном объеме — диспетчеризация, регистрация слушателей, приоритеты, события, которые можно остановить, и встроенный каталог событий - на специальной странице Система событий.
Потоковое промежуточное программное обеспечение¶
Устарело — никогда не запускалось
Ядро никогда не запускало конвейер потокового промежуточного программного обеспечения: getStreamMiddleware() никогда не вызывался
и класс конвейера исчез. StreamMiddlewareProviderInterface,
StreamMiddlewareInterface и StreamContext остаются только существующими модулями, которые
их реализация продолжает загружаться. Не основывайтесь на них. Чтобы воздействовать на потоки, используйте события
например, StreamSavedEvent и StreamsDeletedEvent, или
исходный драйвер.
Зарезервированные слоты на панели навигации¶
| Родительский узел | Гнезда для модулей |
|---|---|
management.service_setup |
order ≥ 60 |
logs.system |
order ≥ 50 |
profile |
order 100–980 |
Журналы - это вкладка верхнего уровня logs с подгруппами logs.connections,
logs.streams, logs.system, logs.users — прикрепите журнал работы модуля
в разделе logs.system. Присоединение дочернего элемента к родительскому ключу, который не существует
автоматически сбрасывает его, поэтому синхронизируйте эти клавиши с CoreNavbarProvider.
Кнопки на верхней панели (TopbarProviderInterface)¶
Значение для каждой страницы верхняя панель (основная кнопка действия и связанные с ней инструменты
выпадающий список над страницей) собирается с помощью XcVm\Core\Util\Topbar. Основные страницы приходят
из собственного списка Topbar; модуль добавляет свои кнопки через
TopbarProviderInterface::registerTopbar(TopbarRegistry $registry), вызванный в
та же фаза загрузки, что и registerNavbar(). BaseModule по умолчанию не запускается,
поэтому переопределяйте его только тогда, когда вам нужны кнопки на верхней панели.
A module can do both of these, in one registerTopbar():
- Внедрить кнопки на существующую основную страницу — передать ключ этой страницы (например,
movies); ваши кнопки будут добавлены после основных. - Создайте свою собственную совершенно новую страницу — передать ключ страницы, который ядру неизвестен
(например,
watch); вся верхняя панель для этой страницы берется из вашего модуля.
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);
}
Клавиша страницы равно AdminHelpers::getPageName() для страницы, на которой отображается кнопка
— то же значение, с которым совпадает верхняя панель.
Форма входа отражает [url, permission, attr] ядро:
| Аргумент | Значение |
|---|---|
$url |
Target page/URL. null for a JS-only action (pair with $attr). |
$permission |
adv дополнительное разрешение для кнопки. null = отображается всегда. |
$attr |
Raw extra attributes: onClick="…", or a well-known id="…". |
$order |
Порядок сортировки среди записей модуля страницы (по возрастанию). |
Оформление заказа и основная кнопка. На странице сначала появляются основные записи, затем
записи в модуле отсортированы по $order. Topbar::items() означает первое
разрешение-сохраняющаяся запись в виде кнопки первичный; остальные попадают в поле
выпадающий. На странице, полностью принадлежащей модулю, самая низкая запись-$order - это
первичный.
Фильтрация разрешений. Каждая запись с ненулевым значением $permission удаляется
если только Authorization::check('adv', $permission) не пройдет, так что кнопки никогда не протекут
к ролям, на которые не имеют права.
Хорошо известные идентификаторы действий в общем случае связаны оболочкой (footer.php) и
управляется ядром, поэтому модулю нужно только выдать идентификатор:
id="…" |
Эффект |
|---|---|
btn-export-csv / btn-export-json |
Экспорт отчета — выводится только на страницу журнала/отчета с основным списком и с разрешением backups. |
btn-clear-logs |
Модальный режим очистки журналов - тип журнала берется из карты core LOG_TYPES для страницы. |
Повторная регистрация того же самого (page, label) переопределяет более раннюю запись
(последние выигрыши), соответствующие NavbarRegistry.
Табличные данные (TableProviderInterface)¶
Серверная таблица данных отправляет свои данные id в конечную точку администратора ./table
(TableController). Идентификаторы основных таблиц - это жестко запрограммированный переключатель; модуль служит
свой СОБСТВЕННЫЙ идентификатор таблицы через TableProviderInterface::registerTables(TableRegistry
$registry) (та же фаза загрузки, что и у других), поэтому разработчик находится в модуле
вместо core. Когда ./table получает идентификатор, который не является регистром core, он выглядит так
в реестре. BaseModule по умолчанию отправлено сообщение о том, что операции не выполняются.
use XcVm\Core\Module\TableRegistry;
public function registerTables(TableRegistry $registry): void
{
$registry->register('watch_output', [WatchController::class, 'tableWatchOutput']);
}
Контракт с обработчиком — 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
}
Правила:
- Обработчик получает скелет ответа (
recordsTotal,recordsFiltered,data) и возвращает его заполненным. Оно должно быть нетechoилиexit—TableControllerJSON - кодирует возвращаемый массив. - Возвращает чистые строки в формате JSON с ключами — без встроенного сервером HTML. Значки статуса, кнопки действий и ссылки отображаются на стороне клиента с помощью представления (то же самое соглашение, которому следуют основные таблицы), что позволяет исключить представление контроллер и позволяет ячейкам, зависящим от разрешений, использовать флаги, выдаваемые представлением.
- Для ветки REST API (
$isApi) повторное использованиеTableController::filterRow($row, $show, $hide)для столбца включить/исключить. - Данные ajax
d.idв представлении должны совпадать с зарегистрированным идентификатором.
Разрешения торгового посредника (PermissionProviderInterface)¶
Каталог дополнительных разрешений для реселлеров редактора группы
(XcVm\Core\Reference\PermissionReference) - это основной список. Модуль добавляет свой
СОБСТВЕННЫЕ ключи доступа через `PermissionProviderInterface::registerPermissions(Регистрация разрешений
$registry)" (та же фаза загрузки), таким образом, модуль владеет разрешениями, на которые он ссылается,
вместо того, чтобы они были жестко запрограммированы в core. Ключи объединяются после списка core.
use XcVm\Core\Module\PermissionRegistry;
public function registerPermissions(PermissionRegistry $registry): void
{
$registry->add('folder_watch');
$registry->add('folder_watch_output');
}
Каждая клавиша отображается в редакторе с метками из переводчика — добавить
permission_<key> и permission_<key>_text языковых записей. Маршруты перехода,
элементы навигационной и верхней панелей на ключе точно такие же, как и при использовании основного разрешения
(Authorization::check('adv', 'folder_watch')); принудительное выполнение считывает сохраненный
групповые разрешения и не зависит от того, где объявлен ключ.
Владение сквозной таблицей модулей. Журнал/таблица данных модуля полностью соответствует модуль: строит свои строки с помощью
TableProviderInterface(чистый JSON) и сохраняет его бухгалтерия также удаляется / очищается / импортируется в модуле — expose module->api(...)направляет действия в строке и реагирует на ядро событие (напримерVodImportedEvent) с помощью#[ListensTo]вместо записи таблицы в ядро непосредственно. Ядро никогда не должно бытьDELETE/UPDATE/TRUNCATEтаблицей, принадлежащей модулю (после удаления модуля он может исчезнуть).
Быстрые инструменты (QuickToolsProviderInterface)¶
Страница "Быстрые инструменты администратора" представляет собой набор одноразовых кнопок обслуживания; каждая из них содержит
его ключ равен post.php?action=quick_tools, который запускает действие сопоставления. Оба
список кнопок и обработчики являются основными. Модуль добавляет свой собственный инструмент — кнопку
и действие — через интерфейс quicktoolsprovider::registerQuickTools(QuickToolsRegistry).
$реестр)`.
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- существующая клавиша табуляции (streams,lines,logs,general, ...) — к нему добавляется инструмент — или новый ключ, отображаемый в виде новой вкладки с общий значок и$groupв качестве его метки-ключа.$label- это клавиша перевода для кнопки.$handler(fn(): void) выполняет действие и должен нет повторить/завершить —post.phpвыдает стандартный JSON-файл{result:true}success после его запуска.
Задача Cron¶
Логика Cron (MyCron.php) — только бизнес-логика, без подключения к интерфейсу командной строки.
Обертка от CronJob (MyCronJob.php) — реализует CommandInterface, использует 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;
}
}
Регистрация в модуле:
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
Объявите запись crontab, переопределив getCronEntries() в классе module:
ModuleLoader::collectCronEntries() объединяет записи всех модулей и StartupCommand /
StatusCommand автоматически записывайте их в системный crontab — никаких изменений в основных файлах не требуется.
Формат: ключ = выражение cron, значение = имя консольной команды, зарегистрированное с помощью registerCommands().
Версионные миграции (MigratableInterface)¶
Два механизма, оба аддитивные. файловая схема, описанный в разделе Структура каталогов модулей (
database.sqlмастер +database_drop.sqlразборка +migrations/<semver>.sqlдельты) используется по умолчанию для обычный DDL/seed.MigratableInterfaceниже приведен программный путь для обновления шаги, требующие логики PHP (обратная загрузка данных, условные изменения). Модуль может использовать один из них или оба;ModuleManager::updateModule()сначала запускает файл delta, затем вызываемые миграции.
Модули, для обновления которых требуется логическая реализация PHP 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() считывает installed_version из хранилища переопределений, фильтрует
сопоставляет только записи > fromVersion && <= toVersion, сортирует по полу и запускает каждую из них.
вызываемый в своей собственной транзакции базы данных. installModule() записи installed_version после
успешная установка; uninstallModule() удаляет ее.
Key rules:
- Ключи - это полустрочные строки (
'1.1.0','2.0.0') —version_compareиспользуется упорядочение - Каждая миграция выполняется в рамках своей собственной транзакции — сбой откатывает только этот шаг
BaseModuleпредоставляет значение по умолчаниюgetMigrations(): array { return []; }, поэтому реализацияMigratableInterfaceявляется необязательным
Исходные драйверы (SourceDriverInterface)¶
Модулю может принадлежать какой-то живой исходный код, который ffmpeg не может прочитать (например, DASH с
DRM) и запустит для него свой собственный движок вместо ffmpeg. Модуль объявляет драйвер
классы в module.json ("source_drivers": [...]), и каждый драйвер задает свой собственный URL
схема. Потоки остаются обычными потоками XC_VM. Смотрите Исходные драйверы для получения
интерфейс, контракт с производителем и полный пример.
Вкладки потоковой формы (StreamFormRegistry)¶
Модуль может добавить свою собственную вкладку на страницу добавления/редактирования потока администратором и сохранить то, что находится на этой вкладке.
записи в своих собственных таблицах. Зарегистрируйте вкладку с boot(). bootAll() сбрасывает реестр
при каждой загрузке, таким образом, вкладка существует только до тех пор, пока загружен ее модуль.
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']);
}
}
- Входные должно быть присвоено имя
module[<id>][<field>], например<input name="module[acme-dash][provider]">. Ядро передает именно этот подмассив назад. Поле модуля никогда не достигает столбцаstreams, и ядро отбрасывает поля из вкладки, которые администратор может не видеть. renderreturns the pane's HTML.$streamis the stream row when editing andnullпри добавлении. Вкладка не отображается в форме импорта, а для массового редактирования нет вкладки модулей.validateвыполняется до того, как что-либо будет записано, и только тогда, когда поля вкладки были заполнены. опубликовано: сохранение API или импорт, который не содержит ничего, никогда не отклоняются им. Возвращающийся строка отказывается сохраняться, и форма показывает этот текст как есть, поэтому переведите его себя.StreamSavedEventотправляется один раз за сохранение, после записи каждой строки. Оно носит:streamIds;isNew:falseдля редактирования;source:form(форма или API администратора),import(M3U) илиreview(Импорт и обзор);moduleFields: идентификатор вкладки => опубликованные поля.
Действуйте только тогда, когда ваш идентификатор находится в moduleFields, в противном случае импорт или сохранение API
это уничтожило бы ваши настройки. Прослушиватель, который выдает сообщение, регистрируется в журнале и не завершает работу
сохранить.
- При сохранении Не добавляйте внешние ключи к streams. строка (REPLACE INTO) будет переписана заново.
Вместо этого выполните очистку на StreamsDeletedEvent.
Виды импорта (ImportSourceRegistry)¶
Модуль может добавить свой собственный источник на страницу Импорт и обзор для прямых трансляций, далее
к встроенному файлу M3U. Администратор выбирает его в селекторе Источник и заполняет
входы модуля. В модуле перечислены каналы, и они проходят через обычный
просмотрите и импортируйте шаги, чтобы каждый канал стал обычным потоком. Зарегистрируйте
вид из boot(); bootAll() сбрасывает реестр при каждой загрузке.
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']
- Входные такого рода называются
import_source[<key>][<field>]. Егоlistcallable получает именно этот подмассив. - Строки нужен
url.titleвозвращает к URL-адресу иlogo,tvg_id(соответствует против EPG, такого как M3Utvg-id) иcategoryявляются необязательными. - Существующие источники. URL-адрес источника, который уже есть в панели, не указывается, если только администратор не галочки Показывают потенциальные дубликаты; затем они отображаются и помечаются.
- Ограничение по строкам. Одна страница обзора занимает не более
ImportSourceRegistry::MAX_ROWS(500) страниц строк; кроме того, на странице отображается слишком много результатов. - Неудачи. Вызываемый объект
list, который выдает сообщение "нет источников" и регистрирует сообщение в журнале, поэтому держите вызовы провайдера в пределах тайм-аута. - После импорта,
StreamSavedEventзапускается с помощьюsource = 'review', и новый идентификаторы потоков. Идентификатор канала указан в URL-адресе источника каждого потока.