Files
xamxam/app/src/PeerTubeService.php

448 lines
16 KiB
PHP

<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
/**
* PeerTubeService
*
* Handles video/audio uploads to a PeerTube instance via its REST API.
*
* Credentials are shared with SmtpRelay: the SMTP username/password are
* reused for PeerTube OAuth2 password-grant authentication. Only
* PeerTube-specific settings (instance URL, channel name, privacy, labels)
* live in peertube_settings.
*
* The channel is stored by its full handle (name@host). The numeric ID
* is resolved via GET /api/v1/video-channels/{handle} at upload time.
*
* OAuth client_id / client_secret are fetched once from the PeerTube
* instance's /api/v1/oauth-clients/local endpoint and cached in-memory
* per process lifetime.
*
* Upload uses the simple multipart upload API:
* POST /api/v1/videos/upload — multipart form upload via Guzzle
*/
class PeerTubeService
{
/** @var array<string,array{client_id:string,client_secret:string}> In-memory OAuth cache. */
private static array $oauthCache = [];
/** @var array<string,int> 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;
}
// -------------------------------------------------------------------------
// 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<string,string>}
*/
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);
}
}