* @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()); } }