In-memory OAuth cache. */ private static array $oauthCache = []; /** @var array In-memory channel name → ID cache. */ private static array $channelCache = []; /** @var Client|null Shared Guzzle client (lazy-init). */ private static ?Client $httpClient = null; // ------------------------------------------------------------------------- // DB CRUD // ------------------------------------------------------------------------- /** * Return PeerTube settings merged with SMTP credentials. * * @return array{instance_url:string,username:string,password:string,channel_name:string,privacy:int,peertube_video_label:string,peertube_audio_label:string} */ public static function getSettings(Database $db): array { $stmt = $db->getPDO()->prepare( 'SELECT instance_url, channel_name, privacy, peertube_video_label, peertube_audio_label FROM peertube_settings WHERE id = 1 LIMIT 1' ); $stmt->execute(); $row = $stmt->fetch() ?: []; require_once __DIR__ . '/SmtpRelay.php'; $smtp = SmtpRelay::getSettings($db); return [ 'instance_url' => $row['instance_url'] ?? '', 'username' => $smtp['username'], 'password' => $smtp['password'], 'channel_name' => $row['channel_name'] ?? '', 'privacy' => (int)($row['privacy'] ?? 1), 'peertube_video_label' => $row['peertube_video_label'] ?? '', 'peertube_audio_label' => $row['peertube_audio_label'] ?? '', ]; } /** * Upsert PeerTube-specific settings. */ public static function updateSettings(Database $db, array $data): void { $current = self::getSettings($db); $merged = array_merge($current, $data); $instanceUrl = rtrim(trim($merged['instance_url']), '/'); $channelName = trim($merged['channel_name'] ?? $current['channel_name'] ?? ''); $privacy = in_array((int)$merged['privacy'], [1, 2, 3], true) ? (int)$merged['privacy'] : 1; $videoLabel = trim($merged['peertube_video_label'] ?? ''); $audioLabel = trim($merged['peertube_audio_label'] ?? ''); $pdo = $db->getPDO(); $exists = $pdo->query('SELECT COUNT(*) FROM peertube_settings WHERE id = 1')->fetchColumn(); if ($exists) { $stmt = $pdo->prepare( 'UPDATE peertube_settings SET instance_url = :url, channel_name = :chan, privacy = :priv, peertube_video_label = :vlabel, peertube_audio_label = :alabel, updated_at = CURRENT_TIMESTAMP WHERE id = 1' ); } else { $stmt = $pdo->prepare( 'INSERT INTO peertube_settings (id, instance_url, channel_name, privacy, peertube_video_label, peertube_audio_label) VALUES (1, :url, :chan, :priv, :vlabel, :alabel)' ); } $stmt->execute([ ':url' => $instanceUrl, ':chan' => $channelName, ':priv' => $privacy, ':vlabel' => $videoLabel, ':alabel' => $audioLabel, ]); } /** * Whether PeerTube is fully configured. */ public static function isConfigured(Database $db): bool { $s = self::getSettings($db); return $s['instance_url'] !== '' && $s['username'] !== '' && $s['channel_name'] !== ''; } /** * Whether the PeerTube upload feature flag is enabled. */ public static function isEnabled(Database $db): bool { return $db->getSetting('peertube_upload_enabled', '0') === '1'; } /** * Test connectivity: obtain a token and resolve the channel. * * @return array{ok:bool, error:string} */ public static function test(Database $db): array { $s = self::getSettings($db); if ($s['instance_url'] === '') { return ['ok' => false, 'error' => "URL de l'instance PeerTube non configurée."]; } if ($s['channel_name'] === '') { return ['ok' => false, 'error' => 'Nom de la chaîne PeerTube non configuré.']; } try { $token = self::obtainToken($s); $chId = self::resolveChannelId($s); if ($chId === null) { return ['ok' => false, 'error' => "Chaîne « {$s['channel_name']} » introuvable sur l'instance."]; } return ['ok' => true, 'error' => '']; } catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; } } // ------------------------------------------------------------------------- // Upload — resumable protocol // ------------------------------------------------------------------------- /** * Upload a local file to PeerTube using the simple multipart upload API. * * @param string $originalName The original client filename (e.g. "video.mp4") sent in the upload form. * @return array{uuid:string, watchUrl:string} * @throws \RuntimeException */ public static function upload( Database $db, string $filePath, string $originalName, string $title, string $description = '' ): array { $s = self::getSettings($db); if ($s['instance_url'] === '') { throw new \RuntimeException('PeerTube non configuré.'); } $channelId = self::resolveChannelId($s); if ($channelId === null) { throw new \RuntimeException('Chaîne PeerTube introuvable : ' . $s['channel_name']); } $token = self::obtainToken($s); $baseUrl = $s['instance_url']; // ── Simple multipart upload (non-resumable) ── $uploadUrl = $baseUrl . '/api/v1/videos/upload'; $multipart = [ ['name' => 'channelId', 'contents' => $channelId], ['name' => 'name', 'contents' => $title], ['name' => 'privacy', 'contents' => (int)$s['privacy']], ['name' => 'commentsEnabled', 'contents' => 'true'], ['name' => 'category', 'contents' => '15'], ['name' => 'videofile', 'contents' => fopen($filePath, 'r'), 'filename' => $originalName], ]; if ($description !== '') { $multipart[] = ['name' => 'description', 'contents' => $description]; } $resp = self::httpRequest($uploadUrl, 'POST', [ 'headers' => ['Authorization' => 'Bearer ' . $token], 'multipart' => $multipart, 'timeout' => 600, ]); if ($resp['status'] < 200 || $resp['status'] >= 300) { $errJson = json_decode($resp['body'], true); $msg = $errJson['error'] ?? $errJson['detail'] ?? $resp['body']; error_log('PeerTubeService: simple upload FAILED | status=' . $resp['status'] . ' | body=' . substr($resp['body'], 0, 500)); throw new \RuntimeException('PeerTube upload failed (' . $resp['status'] . '): ' . $msg); } $json = json_decode($resp['body'], true); $shortUuid = $json['video']['shortUUID'] ?? $json['video']['uuid'] ?? null; if ($shortUuid === null) { error_log('PeerTubeService: simple upload OK but no UUID | body=' . substr($resp['body'], 0, 500)); throw new \RuntimeException('PeerTube upload: no video UUID in response.'); } $watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid; error_log('PeerTubeService: simple upload OK | uuid=' . $shortUuid . ' | watchUrl=' . $watchUrl); return ['uuid' => $shortUuid, 'watchUrl' => $watchUrl]; } // ------------------------------------------------------------------------- // Fetch video info / watch URL // ------------------------------------------------------------------------- /** * Fetch video metadata from PeerTube by UUID or shortUUID. */ public static function fetchVideoInfo(Database $db, string $uuid): ?array { $s = self::getSettings($db); if ($s['instance_url'] === '') { return null; } try { $token = self::obtainToken($s); $url = $s['instance_url'] . '/api/v1/videos/' . urlencode($uuid); $resp = self::httpRequest($url, 'GET', [ 'headers' => ['Authorization' => 'Bearer ' . $token], 'timeout' => 10, ]); if ($resp['status'] !== 200) { return null; } return json_decode($resp['body'], true); } catch (\Throwable $e) { error_log('PeerTubeService::fetchVideoInfo failed: ' . $e->getMessage()); return null; } } /** * Get the watch URL for a PeerTube video by UUID. */ public static function getWatchUrl(Database $db, string $uuid): string { $s = self::getSettings($db); return rtrim($s['instance_url'], '/') . '/videos/watch/' . $uuid; } // ------------------------------------------------------------------------- // Delete // ------------------------------------------------------------------------- /** * Delete a video from PeerTube by UUID or shortUUID. * * DELETE /api/v1/videos/{uuid} * * @return bool true on success, false on failure */ public static function deleteVideo(Database $db, string $uuid): bool { $s = self::getSettings($db); if ($s['instance_url'] === '') { error_log('PeerTubeService::deleteVideo: instance not configured'); return false; } try { $token = self::obtainToken($s); $url = rtrim($s['instance_url'], '/') . '/api/v1/videos/' . urlencode($uuid); $resp = self::httpRequest($url, 'DELETE', [ 'headers' => ['Authorization' => 'Bearer ' . $token], 'timeout' => 30, ]); if ($resp['status'] === 204 || $resp['status'] === 200) { error_log('PeerTubeService: deleted video ' . $uuid); return true; } error_log('PeerTubeService::deleteVideo: unexpected status ' . $resp['status'] . ' for ' . $uuid . ' | body=' . substr($resp['body'], 0, 300)); return false; } catch (\Throwable $e) { error_log('PeerTubeService::deleteVideo failed: ' . $e->getMessage()); return false; } } // ------------------------------------------------------------------------- // Channel resolution // ------------------------------------------------------------------------- /** * Resolve a channel handle (name@host) to its numeric ID via the PeerTube API. * * GET /api/v1/video-channels/{nameWithHost} * * @return int|null The channel numeric ID, or null if not found. */ public static function resolveChannelId(array $s): ?int { $name = $s['channel_name'] ?? ''; if ($name === '' || empty($s['instance_url'])) { return null; } $cacheKey = $s['instance_url'] . '|' . $name; if (isset(self::$channelCache[$cacheKey])) { return self::$channelCache[$cacheKey]; } try { $token = self::obtainToken($s); $url = rtrim($s['instance_url'], '/') . '/api/v1/video-channels/' . urlencode($name); $resp = self::httpRequest($url, 'GET', [ 'headers' => ['Authorization' => 'Bearer ' . $token], 'timeout' => 10, ]); if ($resp['status'] !== 200) { return null; } $json = json_decode($resp['body'], true); $id = (int)($json['id'] ?? 0); if ($id > 0) { self::$channelCache[$cacheKey] = $id; return $id; } return null; } catch (\Throwable $e) { error_log('PeerTubeService::resolveChannelId failed: ' . $e->getMessage()); return null; } } // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- /** * Fetch OAuth client credentials from the PeerTube instance. * Results are cached in-memory per process. */ private static function getOAuthClient(string $instanceUrl): array { $key = $instanceUrl; if (isset(self::$oauthCache[$key])) { return self::$oauthCache[$key]; } $url = rtrim($instanceUrl, '/') . '/api/v1/oauth-clients/local'; $response = self::httpRequest($url, 'GET', ['timeout' => 10]); $json = json_decode($response['body'], true); if ($response['status'] !== 200 || empty($json['client_id'])) { throw new \RuntimeException( 'Impossible de récupérer les identifiants OAuth de l\'instance PeerTube (' . $response['status'] . ').' ); } self::$oauthCache[$key] = [ 'client_id' => $json['client_id'], 'client_secret' => $json['client_secret'], ]; return self::$oauthCache[$key]; } /** * Obtain an OAuth2 access token via password grant. */ private static function obtainToken(array $s): string { $oauth = self::getOAuthClient($s['instance_url']); $tokenUrl = $s['instance_url'] . '/api/v1/users/token'; $body = http_build_query([ 'client_id' => $oauth['client_id'], 'client_secret' => $oauth['client_secret'], 'grant_type' => 'password', 'response_type' => 'code', 'username' => $s['username'], 'password' => $s['password'], ]); $response = self::httpRequest($tokenUrl, 'POST', [ 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], 'body' => $body, ]); $json = json_decode($response['body'], true); if ($response['status'] !== 200 || empty($json['access_token'])) { $msg = $json['error_description'] ?? $json['error'] ?? $response['body']; throw new \RuntimeException('PeerTube auth failed (' . $response['status'] . '): ' . $msg); } return $json['access_token']; } // ------------------------------------------------------------------------- // HTTP helper // ------------------------------------------------------------------------- /** * Shared Guzzle HTTP client (lazy-init with SSL verification, HTTP/2, 15s connect timeout). */ private static function client(): Client { if (self::$httpClient === null) { self::$httpClient = new Client([ 'http_errors' => false, 'allow_redirects' => false, 'connect_timeout' => 15, 'version' => 2.0, // HTTP/2 ]); } return self::$httpClient; } /** * Perform an HTTP request via Guzzle. * * @param array $options Guzzle request options (headers, body, multipart, timeout, etc.) * @return array{status:int, body:string, headers:array} */ public static function httpRequest(string $url, string $method, array $options = []): array { try { $response = self::client()->request($method, $url, $options); $headers = []; foreach ($response->getHeaders() as $name => $values) { $headers[strtolower($name)] = end($values); } return [ 'status' => $response->getStatusCode(), 'body' => (string)$response->getBody(), 'headers' => $headers, ]; } catch (GuzzleException $e) { throw new \RuntimeException('Erreur réseau PeerTube : ' . $e->getMessage(), 0, $e); } } // ------------------------------------------------------------------------- // Progress reporting (for upload-progress.js polling) // ------------------------------------------------------------------------- /** * Write upload progress to a temp file polled by the progress endpoint. */ public static function writeProgress(string $token, string $stage, int $pct, string $file = ''): void { $progressFile = sys_get_temp_dir() . '/xamxam_upload_' . $token . '.json'; file_put_contents($progressFile, json_encode([ 'stage' => $stage, 'pct' => $pct, 'file' => $file, ]), LOCK_EX); } /** * Remove the progress file for a given token. */ public static function clearProgress(string $token): void { $progressFile = sys_get_temp_dir() . '/xamxam_upload_' . $token . '.json'; @unlink($progressFile); } }