Files
XC_VM/src/cli/Commands/RecordCommand.php
T

324 lines
12 KiB
PHP
Raw Normal View History

<?php
/**
* RecordCommand — record command
*
* @package XC_VM_CLI_Commands
* @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 RecordCommand implements CommandInterface {
public function getName(): string {
return 'record';
}
public function getDescription(): string {
return 'Record — record stream to MP4';
}
public function execute(array $rArgs): int {
if (posix_getpwuid(posix_geteuid())['name'] != 'xc_vm') {
echo "Please run as XC_VM!\n";
return 1;
}
if (empty($rArgs[0])) {
return 0;
}
$recordingID = intval($rArgs[0]);
register_shutdown_function(function () use ($recordingID) {
global $db;
if (file_exists(ARCHIVE_PATH . $recordingID . '_.record')) {
unlink(ARCHIVE_PATH . $recordingID . '_.record');
}
if (is_object($db)) {
$db->close_mysql();
}
});
$this->checkRunning($recordingID);
set_time_limit(0);
cli_set_process_title('Record[' . $recordingID . ']');
global $db;
$db->query('SELECT * FROM `recordings` WHERE `id` = ?;', $recordingID);
if ($db->num_rows() <= 0) {
echo "Recording entry doesn't exist.\n";
return 0;
}
$rFails = $totalBytes = 0;
$isComplete = false;
$recordingData = $db->get_row();
if (!($recordingData['start'] - 60 <= time() && time() <= $recordingData['end'] || $recordingData['archive'])) {
echo "Programme is not currently airing.\n";
$db->query('UPDATE `recordings` SET `status` = 3 WHERE `id` = ?;', $recordingID);
@unlink(ARCHIVE_PATH . $recordingID . '.ts');
return 0;
}
$rPID = (file_exists(STREAMS_PATH . $recordingData['stream_id'] . '_.pid') ? intval(file_get_contents(STREAMS_PATH . $recordingData['stream_id'] . '_.pid')) : 0);
$rPlaylist = STREAMS_PATH . $recordingData['stream_id'] . '_.m3u8';
if ($rPID <= 0 || !file_exists($rPlaylist)) {
echo "Channel is not running.\n";
$this->finishRecording($db, $recordingID, false);
return 0;
}
$db->query('UPDATE `recordings` SET `status` = 1 WHERE `id` = ?;', $recordingID);
$db->close_mysql();
while (ProcessManager::isStreamRunning($rPID, $recordingData['stream_id']) && file_exists($rPlaylist)) {
if ($recordingData['archive']) {
$rDuration = intval(($recordingData['end'] - $recordingData['start']) / 60);
$rFP = @fopen('http://127.0.0.1:' . ServerRepository::getAll()[SERVER_ID]['http_broadcast_port'] . '/admin/timeshift?password=' . SettingsManager::getAll()['live_streaming_pass'] . '&stream=' . $recordingData['stream_id'] . '&start=' . $recordingData['start'] . '&duration=' . $rDuration . '&extension=ts', 'r');
} else {
$rFP = @fopen('http://127.0.0.1:' . ServerRepository::getAll()[SERVER_ID]['http_broadcast_port'] . '/admin/live?password=' . SettingsManager::getAll()['live_streaming_pass'] . '&stream=' . $recordingData['stream_id'] . '&extension=ts', 'r');
}
if ($rFP) {
echo "Recording...\n";
if ($recordingData['archive']) {
$rWriteFile = fopen(ARCHIVE_PATH . $recordingID . '.ts', 'w');
} else {
$rWriteFile = fopen(ARCHIVE_PATH . $recordingID . '.ts', 'a');
}
while (!feof($rFP)) {
$rData = stream_get_line($rFP, 4096);
if (!empty($rData)) {
$totalBytes += $rData;
fwrite($rWriteFile, $rData);
fflush($rWriteFile);
$rFails = 0;
}
if (!($recordingData['end'] > time() || $recordingData['archive'])) {
$isComplete = true;
fclose($rWriteFile);
}
}
fclose($rFP);
if ($recordingData['archive']) {
$isComplete = true;
}
}
$rFails++;
if ($rFails == 5) {
if ($totalBytes >= 10485760) {
$isComplete = true;
}
echo "Too many fails!\n";
break;
}
echo "Broken pipe! Restarting...\n";
sleep(1);
}
if (!$db->connected) {
$db->db_connect();
}
if ($isComplete) {
$this->processRecording($db, $recordingID, $recordingData);
} else {
$this->finishRecording($db, $recordingID, false);
}
return 0;
}
private function processRecording($db, $recordingID, $recordingData): void {
if (!file_exists(ARCHIVE_PATH . $recordingID . '.ts') || filesize(ARCHIVE_PATH . $recordingID . '.ts') <= 0) {
echo "Recording size is 0 bytes.\n";
$this->finishRecording($db, $recordingID, false);
return;
}
echo "Recording complete! Converting to MP4...\n";
if (!empty($recordingData['stream_icon'])) {
$recordingData['stream_icon'] = $this->downloadAndSaveImage($recordingData['stream_icon']);
}
$rSeconds = intval($recordingData['end'] - $recordingData['start']);
$rImportArray = $this->verifyPostTable($db, 'streams');
$rImportArray['type'] = 2;
$rImportArray['stream_source'] = '[]';
$rImportArray['target_container'] = 'mp4';
$rImportArray['stream_display_name'] = $recordingData['title'];
$rImportArray['year'] = date('Y');
$rImportArray['movie_properties'] = array('kinopoisk_url' => null, 'tmdb_id' => null, 'name' => $recordingData['title'], 'o_name' => $recordingData['title'], 'cover_big' => $recordingData['stream_icon'], 'movie_image' => $recordingData['stream_icon'], 'release_date' => date('Y-m-d', $recordingData['start']), 'episode_run_time' => intval($rSeconds / 60), 'youtube_trailer' => null, 'director' => '', 'actors' => '', 'cast' => '', 'description' => trim($recordingData['description']), 'plot' => trim($recordingData['description']), 'age' => '', 'mpaa_rating' => '', 'rating_count_kinopoisk' => 0, 'country' => '', 'genre' => '', 'backdrop_path' => array(), 'duration_secs' => $rSeconds, 'duration' => sprintf('%02d:%02d:%02d', $rSeconds / 3600, ($rSeconds / 60) % 60, $rSeconds % 60), 'video' => array(), 'audio' => array(), 'bitrate' => 0, 'rating' => 0);
$rImportArray['rating'] = 0;
$rImportArray['read_native'] = 0;
$rImportArray['movie_symlink'] = 0;
$rImportArray['remove_subtitles'] = 0;
$rImportArray['transcode_profile_id'] = 0;
$rImportArray['order'] = $this->getNextOrder($db);
$rImportArray['added'] = time();
$rImportArray['category_id'] = '[' . implode(',', array_map('intval', json_decode($recordingData['category_id'], true))) . ']';
$rPrepare = $this->prepareArray($rImportArray);
$rQuery = 'REPLACE INTO `streams`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');';
if (!$db->query($rQuery, ...$rPrepare['data'])) {
echo "Failed to insert into database!\n";
$this->finishRecording($db, $recordingID, false);
return;
}
$rInsertID = $db->last_insert_id();
shell_exec(FFMPEG_BIN_40 . " -i '" . ARCHIVE_PATH . $recordingID . '.ts' . "' -c:v copy -c:a copy '" . VOD_PATH . $rInsertID . '.mp4' . "'");
@unlink(ARCHIVE_PATH . $recordingID . '.ts');
if (!file_exists(VOD_PATH . $rInsertID . '.mp4')) {
echo "Couldn't convert to MP4\n";
$this->finishRecording($db, $recordingID, false);
return;
}
foreach (json_decode($recordingData['bouquets'], true) as $rBouquet) {
$this->addToBouquet($db, $rBouquet, $rInsertID);
}
$db->query('UPDATE `streams` SET `stream_source` = ? WHERE `id` = ?;', json_encode(array(VOD_PATH . $rInsertID . '.mp4')), $rInsertID);
$db->query('INSERT INTO `streams_servers`(`stream_id`, `server_id`, `parent_id`, `pid`, `to_analyze`) VALUES(?, ?, NULL, 1, 1);', $rInsertID, SERVER_ID);
$db->query('UPDATE `recordings` SET `status` = 2, `created_id` = ? WHERE `id` = ?;', $rInsertID, $recordingID);
}
private function finishRecording($db, $recordingID, $success): void {
if (!$success) {
echo "Recording incomplete!\n";
$db->query('UPDATE `recordings` SET `status` = 3 WHERE `id` = ?;', $recordingID);
@unlink(ARCHIVE_PATH . $recordingID . '.ts');
}
}
private function downloadAndSaveImage($rImage) {
if (strlen($rImage) <= 0 || substr(strtolower($rImage), 0, 4) != 'http') {
return null;
}
$rFilename = md5($rImage);
$rExt = 'jpg';
$rPrevPath = IMAGES_PATH . $rFilename . '.' . $rExt;
if (file_exists($rPrevPath)) {
return 's:' . SERVER_ID . ':/images/' . $rFilename . '.' . $rExt;
}
$rCurl = curl_init();
curl_setopt($rCurl, CURLOPT_URL, $rImage);
curl_setopt($rCurl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($rCurl, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($rCurl, CURLOPT_TIMEOUT, 5);
$rData = curl_exec($rCurl);
if (strlen($rData) <= 0) {
return null;
}
$rPath = IMAGES_PATH . $rFilename . '.' . $rExt;
file_put_contents($rPath, $rData);
if (file_exists($rPath)) {
return 's:' . SERVER_ID . ':/images/' . $rFilename . '.' . $rExt;
}
return null;
}
private function getNextOrder($db): int {
$db->query('SELECT MAX(`order`) AS `order` FROM `streams`;');
if ($db->num_rows() != 1) {
return 0;
}
return intval($db->get_row()['order']) + 1;
}
private function getBouquet($db, $rID) {
$db->query('SELECT * FROM `bouquets` WHERE `id` = ?;', $rID);
if ($db->num_rows() != 1) {
return null;
}
return $db->get_row();
}
private function addToBouquet($db, $rBouquetID, $rID): void {
$rBouquet = $this->getBouquet($db, $rBouquetID);
if (!$rBouquet) {
return;
}
$rMovies = json_decode($rBouquet['bouquet_movies'], true);
if (!in_array($rID, $rMovies)) {
$rMovies[] = intval($rID);
}
$rMovies = '[' . implode(',', array_map('intval', $rMovies)) . ']';
$db->query('UPDATE `bouquets` SET `bouquet_movies` = ? WHERE `id` = ?;', $rMovies, $rBouquetID);
}
private function preparecolumn($rValue): string {
return strtolower(preg_replace('/[^a-z0-9_]+/i', '', $rValue));
}
private function prepareArray($rArray): array {
$UpdateData = $rColumns = $rPlaceholder = $rData = array();
foreach (array_keys($rArray) as $rKey) {
$rColumns[] = '`' . $this->preparecolumn($rKey) . '`';
$UpdateData[] = '`' . $this->preparecolumn($rKey) . '` = ?';
}
foreach (array_values($rArray) as $rValue) {
if (is_array($rValue)) {
$rValue = json_encode($rValue, JSON_UNESCAPED_UNICODE);
}
$rPlaceholder[] = '?';
$rData[] = $rValue;
}
return array('placeholder' => implode(',', $rPlaceholder), 'columns' => implode(',', $rColumns), 'data' => $rData, 'update' => implode(',', $UpdateData));
}
private function verifyPostTable($db, $rTable, $rData = array(), $rOnlyExisting = false): array {
$rReturn = array();
$db->query('SELECT `column_name`, `column_default`, `is_nullable`, `data_type` FROM `information_schema`.`columns` WHERE `table_schema` = (SELECT DATABASE()) AND `table_name` = ? ORDER BY `ordinal_position`;', $rTable);
foreach ($db->get_rows() as $rRow) {
if ($rRow['column_default'] == 'NULL') {
$rRow['column_default'] = null;
}
$rForceDefault = false;
if (!($rRow['is_nullable'] != 'NO' || $rRow['column_default'])) {
if (in_array($rRow['data_type'], array('int', 'float', 'tinyint', 'double', 'decimal', 'smallint', 'mediumint', 'bigint', 'bit'))) {
$rRow['column_default'] = 0;
} else {
$rRow['column_default'] = '';
}
$rForceDefault = true;
}
if (array_key_exists($rRow['column_name'], $rData)) {
if (empty($rData[$rRow['column_name']]) && !is_numeric($rData[$rRow['column_name']]) && is_null($rRow['column_default'])) {
$rReturn[$rRow['column_name']] = ($rForceDefault ? $rRow['column_default'] : null);
} else {
$rReturn[$rRow['column_name']] = $rData[$rRow['column_name']];
}
} else {
if (!$rOnlyExisting) {
$rReturn[$rRow['column_name']] = $rRow['column_default'];
}
}
}
return $rReturn;
}
private function checkRunning($recordingID): void {
clearstatcache(true);
$rPID = null;
if (file_exists(ARCHIVE_PATH . $recordingID . '_.record')) {
$rPID = intval(file_get_contents(ARCHIVE_PATH . $recordingID . '_.record'));
}
if (empty($rPID)) {
shell_exec("kill -9 `ps -ef | grep 'Record\\[" . intval($recordingID) . "\\]' | grep -v grep | awk '{print \$2}'`;");
} else {
if (file_exists('/proc/' . $rPID)) {
$rCommand = trim(file_get_contents('/proc/' . $rPID . '/cmdline'));
if ($rCommand == 'Record[' . $recordingID . ']' && is_numeric($rPID) && 0 < $rPID) {
posix_kill($rPID, 9);
}
}
}
file_put_contents(ARCHIVE_PATH . $recordingID . '_.record', getmypid());
}
}