mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
feat: PeerTube integration — alternate audio/video labels, FilePond pools, shared SMTP credentials, channel by name, test button, resumable upload, embed improvements, fix alt labels/curl_close/deprecation
This commit is contained in:
@@ -209,9 +209,11 @@ class ThesisCreateController
|
||||
$nextNum = $this->handleTfeQueueFiles($thesisId, $qAudio, $folderPath, $filePrefix, $nextNum);
|
||||
$this->handleAnnexeQueueFiles($thesisId, $qAnnexe, $folderPath, $filePrefix);
|
||||
|
||||
// ── 5b. PeerTube video / audio uploads ────────────────────────────────
|
||||
$this->handlePeerTubeUpload($thesisId, $data['titre'], $files, 'peertube_video');
|
||||
$this->handlePeerTubeUpload($thesisId, $data['titre'], $files, 'peertube_audio');
|
||||
// ── 5b. PeerTube video / audio uploads (from FilePond queue) ──────────
|
||||
$qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video');
|
||||
$qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio');
|
||||
$this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTVideo, 'video');
|
||||
$this->handlePeerTubeQueueFiles($thesisId, $data['titre'], $qPTAudio, 'audio');
|
||||
|
||||
// ── 6. Website URL — stored as thesis_files row ──────────────────────
|
||||
$this->handleWebsiteUrl($thesisId, $post);
|
||||
@@ -581,17 +583,19 @@ class ThesisCreateController
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a video or audio file to PeerTube when the feature is enabled.
|
||||
* Upload PeerTube video/audio files from FilePond queue.
|
||||
*
|
||||
* @param int $thesisId Thesis to attach the result to.
|
||||
* @param string $title Title to use on PeerTube.
|
||||
* @param array $files $_FILES array.
|
||||
* @param string $inputName 'peertube_video' or 'peertube_audio'.
|
||||
* Files arrive via PHP's nested $_FILES structure from
|
||||
* <input name="queue_file[peertube_video][]">.
|
||||
*
|
||||
* @param int $thesisId Thesis to attach the results to.
|
||||
* @param string $title Title to use on PeerTube.
|
||||
* @param array|null $uploads Flat $_FILES-style array from extractFilesSubArray().
|
||||
* @param string $fileType 'video' or 'audio'.
|
||||
*/
|
||||
protected function handlePeerTubeUpload(int $thesisId, string $title, array $files, string $inputName): void
|
||||
protected function handlePeerTubeQueueFiles(int $thesisId, string $title, ?array $uploads, string $fileType): void
|
||||
{
|
||||
$upload = $files[$inputName] ?? null;
|
||||
if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
|
||||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -600,29 +604,37 @@ class ThesisCreateController
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$watchUrl = PeerTubeService::upload(
|
||||
$this->db,
|
||||
$upload['tmp_name'],
|
||||
$title,
|
||||
''
|
||||
);
|
||||
$count = count($uploads['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileType = str_contains($inputName, 'audio') ? 'audio' : 'video';
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$watchUrl, // stored as the watch URL (no local file)
|
||||
basename($upload['name']),
|
||||
$upload['size'],
|
||||
$upload['type'] ?? 'application/octet-stream',
|
||||
null,
|
||||
null
|
||||
);
|
||||
error_log("ThesisCreateController: PeerTube upload OK → $watchUrl");
|
||||
} catch (\Throwable $e) {
|
||||
error_log('ThesisCreateController: PeerTube upload failed — ' . $e->getMessage());
|
||||
// Non-fatal: the thesis is already saved; admin can re-upload manually.
|
||||
try {
|
||||
$result = PeerTubeService::upload(
|
||||
$this->db,
|
||||
$uploads['tmp_name'][$i],
|
||||
$title,
|
||||
''
|
||||
);
|
||||
|
||||
// Store as peertube_ids:{uuid} so the embed template can extract the UUID
|
||||
$storedPath = 'peertube_ids:' . $result['uuid'];
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$storedPath,
|
||||
basename($uploads['name'][$i]),
|
||||
$uploads['size'][$i],
|
||||
$uploads['type'][$i] ?? 'application/octet-stream',
|
||||
null,
|
||||
null
|
||||
);
|
||||
error_log("ThesisCreateController: PeerTube upload OK → " . $result['watchUrl']);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('ThesisCreateController: PeerTube upload failed — ' . $e->getMessage());
|
||||
// Non-fatal: thesis already saved; admin can re-upload manually.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -437,9 +437,11 @@ class ThesisEditController
|
||||
$this->handleAnnexeFiles($thesisId, $files['annexes'], $folderPath, $filePrefix, $post);
|
||||
}
|
||||
|
||||
// ── PeerTube video / audio uploads ────────────────────────────────────
|
||||
$this->handlePeerTubeUpload($thesisId, trim($post['titre'] ?? ''), $files, 'peertube_video');
|
||||
$this->handlePeerTubeUpload($thesisId, trim($post['titre'] ?? ''), $files, 'peertube_audio');
|
||||
// ── PeerTube video / audio uploads (from FilePond queue) ──────────────
|
||||
$qPTVideo = $this->extractFilesSubArray($queueFiles, 'peertube_video');
|
||||
$qPTAudio = $this->extractFilesSubArray($queueFiles, 'peertube_audio');
|
||||
$this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTVideo, 'video');
|
||||
$this->handlePeerTubeQueueFiles($thesisId, trim($post['titre'] ?? ''), $qPTAudio, 'audio');
|
||||
|
||||
// ── Website URL — add or update ──────────────────────────────────────
|
||||
$this->handleWebsiteUrl($thesisId, $post);
|
||||
@@ -571,17 +573,19 @@ class ThesisEditController
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a video or audio file to PeerTube when the feature is enabled.
|
||||
* Upload PeerTube video/audio files from FilePond queue.
|
||||
*
|
||||
* @param int $thesisId Thesis to attach the result to.
|
||||
* @param string $title Title to use on PeerTube.
|
||||
* @param array $files $_FILES array.
|
||||
* @param string $inputName 'peertube_video' or 'peertube_audio'.
|
||||
* Files arrive via PHP's nested $_FILES structure from
|
||||
* <input name="queue_file[peertube_video][]">.
|
||||
*
|
||||
* @param int $thesisId Thesis to attach the results to.
|
||||
* @param string $title Title to use on PeerTube.
|
||||
* @param array|null $uploads Flat $_FILES-style array from extractFilesSubArray().
|
||||
* @param string $fileType 'video' or 'audio'.
|
||||
*/
|
||||
private function handlePeerTubeUpload(int $thesisId, string $title, array $files, string $inputName): void
|
||||
private function handlePeerTubeQueueFiles(int $thesisId, string $title, ?array $uploads, string $fileType): void
|
||||
{
|
||||
$upload = $files[$inputName] ?? null;
|
||||
if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
|
||||
if (!$uploads || !is_array($uploads['name'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -590,28 +594,35 @@ class ThesisEditController
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$watchUrl = PeerTubeService::upload(
|
||||
$this->db,
|
||||
$upload['tmp_name'],
|
||||
$title,
|
||||
''
|
||||
);
|
||||
$count = count($uploads['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileType = str_contains($inputName, 'audio') ? 'audio' : 'video';
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$watchUrl,
|
||||
basename($upload['name']),
|
||||
$upload['size'],
|
||||
$upload['type'] ?? 'application/octet-stream',
|
||||
null,
|
||||
null
|
||||
);
|
||||
error_log("ThesisEditController: PeerTube upload OK → $watchUrl");
|
||||
} catch (\Throwable $e) {
|
||||
error_log('ThesisEditController: PeerTube upload failed — ' . $e->getMessage());
|
||||
try {
|
||||
$result = PeerTubeService::upload(
|
||||
$this->db,
|
||||
$uploads['tmp_name'][$i],
|
||||
$title,
|
||||
''
|
||||
);
|
||||
|
||||
$storedPath = 'peertube_ids:' . $result['uuid'];
|
||||
$this->db->insertThesisFile(
|
||||
$thesisId,
|
||||
$fileType,
|
||||
$storedPath,
|
||||
basename($uploads['name'][$i]),
|
||||
$uploads['size'][$i],
|
||||
$uploads['type'][$i] ?? 'application/octet-stream',
|
||||
null,
|
||||
null
|
||||
);
|
||||
error_log("ThesisEditController: PeerTube upload OK → " . $result['watchUrl']);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('ThesisEditController: PeerTube upload failed — ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,108 +3,118 @@
|
||||
/**
|
||||
* PeerTubeService
|
||||
*
|
||||
* Handles credential storage and video/audio uploads to a PeerTube instance
|
||||
* via its REST API. Follows the same patterns as SmtpRelay:
|
||||
* - Static CRUD on a dedicated settings table (peertube_settings)
|
||||
* - A feature-flag setting (peertube_upload_enabled) in site_settings
|
||||
* - An upload() method that POSTs a file to the PeerTube /api/v1/videos/upload
|
||||
* endpoint and returns the resulting watch URL
|
||||
* Handles video/audio uploads to a PeerTube instance via its REST API.
|
||||
*
|
||||
* PeerTube API reference:
|
||||
* POST /api/v1/videos/upload — resumable / direct upload
|
||||
* POST /api/v1/users/token — OAuth2 password grant
|
||||
* 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 stored access token is refreshed automatically when it expires (401).
|
||||
* Credentials (password + token) are encrypted at rest via Crypto.php.
|
||||
* 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 resumable protocol:
|
||||
* POST /api/v1/videos/upload-resumable — init
|
||||
* PUT /api/v1/videos/upload-resumable — send chunk
|
||||
* DELETE /api/v1/videos/upload-resumable — cancel
|
||||
*/
|
||||
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 = [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DB CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return current PeerTube settings from the DB.
|
||||
* Return PeerTube settings merged with SMTP credentials.
|
||||
*
|
||||
* @return array{instance_url:string,username:string,password:string,channel_id:int,privacy:int}
|
||||
* @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, username, password, channel_id, privacy
|
||||
'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();
|
||||
$row = $stmt->fetch() ?: [];
|
||||
|
||||
if ($row) {
|
||||
require_once __DIR__ . '/Crypto.php';
|
||||
$row['password'] = Crypto::decrypt($row['password']);
|
||||
}
|
||||
require_once __DIR__ . '/SmtpRelay.php';
|
||||
$smtp = SmtpRelay::getSettings($db);
|
||||
|
||||
return $row ?: [
|
||||
'instance_url' => '',
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'channel_id' => 1,
|
||||
'privacy' => 1, // 1=Public, 2=Unlisted, 3=Private
|
||||
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 settings.
|
||||
* Upsert PeerTube-specific settings.
|
||||
*/
|
||||
public static function updateSettings(Database $db, array $data): void
|
||||
{
|
||||
$current = self::getSettings($db);
|
||||
$merged = array_merge($current, $data);
|
||||
|
||||
require_once __DIR__ . '/Crypto.php';
|
||||
|
||||
// Normalise instance URL: strip trailing slash
|
||||
$instanceUrl = rtrim(trim($merged['instance_url']), '/');
|
||||
$channelId = max(1, (int)$merged['channel_id']);
|
||||
$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();
|
||||
|
||||
// Upsert row (id=1 is always the singleton row)
|
||||
$exists = $pdo->query('SELECT COUNT(*) FROM peertube_settings WHERE id = 1')->fetchColumn();
|
||||
if ($exists) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE peertube_settings
|
||||
SET instance_url = :url,
|
||||
username = :user,
|
||||
password = :pass,
|
||||
channel_id = :chan,
|
||||
privacy = :priv,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
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, username, password, channel_id, privacy)
|
||||
VALUES (1, :url, :user, :pass, :chan, :priv)'
|
||||
'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,
|
||||
':user' => trim($merged['username']),
|
||||
':pass' => Crypto::encrypt($merged['password']),
|
||||
':chan' => $channelId,
|
||||
':priv' => $privacy,
|
||||
':url' => $instanceUrl,
|
||||
':chan' => $channelName,
|
||||
':priv' => $privacy,
|
||||
':vlabel' => $videoLabel,
|
||||
':alabel' => $audioLabel,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether PeerTube credentials are fully configured.
|
||||
* Whether PeerTube is fully configured.
|
||||
*/
|
||||
public static function isConfigured(Database $db): bool
|
||||
{
|
||||
$s = self::getSettings($db);
|
||||
return $s['instance_url'] !== '' && $s['username'] !== '';
|
||||
return $s['instance_url'] !== '' && $s['username'] !== '' && $s['channel_name'] !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +126,7 @@ class PeerTubeService
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connectivity: obtain a token without uploading anything.
|
||||
* Test connectivity: obtain a token and resolve the channel.
|
||||
*
|
||||
* @return array{ok:bool, error:string}
|
||||
*/
|
||||
@@ -126,8 +136,15 @@ class PeerTubeService
|
||||
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 {
|
||||
self::obtainToken($s);
|
||||
$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()];
|
||||
@@ -135,97 +152,266 @@ class PeerTubeService
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Upload
|
||||
// Upload — resumable protocol
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Upload a local file to PeerTube.
|
||||
* Upload a local file to PeerTube using the resumable upload protocol.
|
||||
*
|
||||
* @param string $filePath Absolute path to the file on disk.
|
||||
* @param string $title Video/audio title shown on PeerTube.
|
||||
* @param string $description Optional description.
|
||||
* @return string The public watch URL of the uploaded video.
|
||||
* @throws \RuntimeException On any API or network error.
|
||||
* @return array{uuid:string, watchUrl:string}
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public static function upload(
|
||||
Database $db,
|
||||
string $filePath,
|
||||
string $title,
|
||||
string $description = ''
|
||||
): string {
|
||||
): array {
|
||||
$s = self::getSettings($db);
|
||||
if ($s['instance_url'] === '') {
|
||||
throw new \RuntimeException('PeerTube non configuré.');
|
||||
}
|
||||
|
||||
$token = self::obtainToken($s);
|
||||
|
||||
$baseUrl = $s['instance_url'];
|
||||
$uploadUrl = $baseUrl . '/api/v1/videos/upload';
|
||||
|
||||
// Build multipart body
|
||||
$boundary = '----XamxamPT' . bin2hex(random_bytes(8));
|
||||
$body = '';
|
||||
|
||||
$fields = [
|
||||
'channelId' => (string)$s['channel_id'],
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'privacy' => (string)$s['privacy'],
|
||||
'waitTranscoding' => 'false',
|
||||
];
|
||||
foreach ($fields as $k => $v) {
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"{$k}\"\r\n\r\n";
|
||||
$body .= $v . "\r\n";
|
||||
$channelId = self::resolveChannelId($s);
|
||||
if ($channelId === null) {
|
||||
throw new \RuntimeException('Chaîne PeerTube introuvable : ' . $s['channel_name']);
|
||||
}
|
||||
|
||||
// File part
|
||||
$token = self::obtainToken($s);
|
||||
$baseUrl = $s['instance_url'];
|
||||
$fileSize = filesize($filePath);
|
||||
$fileName = basename($filePath);
|
||||
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($filePath);
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Disposition: form-data; name=\"videofile\"; filename=\"{$fileName}\"\r\n";
|
||||
$body .= "Content-Type: {$mimeType}\r\n\r\n";
|
||||
$body .= file_get_contents($filePath) . "\r\n";
|
||||
$body .= "--{$boundary}--\r\n";
|
||||
|
||||
$response = self::httpRequest($uploadUrl, 'POST', $body, [
|
||||
// ── Step 1: Initialize resumable upload ──
|
||||
$initUrl = $baseUrl . '/api/v1/videos/upload-resumable';
|
||||
$initData = [
|
||||
'channelId' => $channelId,
|
||||
'name' => $title,
|
||||
'privacy' => (int)$s['privacy'],
|
||||
'waitTranscoding' => false,
|
||||
'filename' => $fileName,
|
||||
];
|
||||
if ($description !== '') {
|
||||
$initData['description'] = $description;
|
||||
}
|
||||
$initBody = json_encode($initData);
|
||||
|
||||
$initResponse = self::httpRequest($initUrl, 'POST', $initBody, [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Content-Type: multipart/form-data; boundary=' . $boundary,
|
||||
'Content-Length: ' . strlen($body),
|
||||
'Content-Type: application/json',
|
||||
'X-Upload-Content-Length: ' . $fileSize,
|
||||
'X-Upload-Content-Type: ' . $mimeType,
|
||||
'Content-Length: ' . strlen($initBody),
|
||||
]);
|
||||
|
||||
$json = json_decode($response['body'], true);
|
||||
|
||||
if ($response['status'] < 200 || $response['status'] >= 300) {
|
||||
$msg = $json['error'] ?? $json['detail'] ?? $response['body'];
|
||||
throw new \RuntimeException('PeerTube upload failed (' . $response['status'] . '): ' . $msg);
|
||||
$initJson = json_decode($initResponse['body'], true);
|
||||
if ($initResponse['status'] < 200 || $initResponse['status'] >= 300) {
|
||||
$msg = $initJson['error'] ?? $initJson['detail'] ?? $initResponse['body'];
|
||||
throw new \RuntimeException('PeerTube upload init failed (' . $initResponse['status'] . '): ' . $msg);
|
||||
}
|
||||
|
||||
$shortUuid = $json['video']['shortUUID'] ?? $json['video']['uuid'] ?? null;
|
||||
// Small files may complete in one shot
|
||||
$shortUuid = $initJson['video']['shortUUID'] ?? $initJson['video']['uuid'] ?? null;
|
||||
if ($shortUuid && !isset($initJson['video']['id'])) {
|
||||
$watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
|
||||
return ['uuid' => $shortUuid, 'watchUrl' => $watchUrl];
|
||||
}
|
||||
|
||||
$uploadId = $initJson['video']['id'] ?? null;
|
||||
if (!$uploadId) {
|
||||
throw new \RuntimeException('PeerTube upload init: no upload session returned.');
|
||||
}
|
||||
|
||||
// ── Step 2: Send chunks ──
|
||||
$chunkUrl = $baseUrl . '/api/v1/videos/upload-resumable?upload_id=' . urlencode((string)$uploadId);
|
||||
|
||||
$fh = fopen($filePath, 'rb');
|
||||
if (!$fh) {
|
||||
throw new \RuntimeException('Cannot open file for resumable upload.');
|
||||
}
|
||||
|
||||
$chunkSize = 4 * 1024 * 1024;
|
||||
$offset = 0;
|
||||
$lastResponse = null;
|
||||
|
||||
while ($offset < $fileSize) {
|
||||
$chunk = fread($fh, $chunkSize);
|
||||
$chunkLen = strlen($chunk);
|
||||
$end = $offset + $chunkLen - 1;
|
||||
|
||||
$resp = self::httpRequest($chunkUrl, 'PUT', $chunk, [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Content-Type: application/octet-stream',
|
||||
'Content-Range: bytes ' . $offset . '-' . $end . '/' . $fileSize,
|
||||
'Content-Length: ' . $chunkLen,
|
||||
], 600);
|
||||
|
||||
$offset += $chunkLen;
|
||||
|
||||
if ($resp['status'] >= 200 && $resp['status'] < 300) {
|
||||
$json = json_decode($resp['body'], true);
|
||||
if (isset($json['video']['shortUUID']) || isset($json['video']['uuid'])) {
|
||||
$lastResponse = $resp;
|
||||
break;
|
||||
}
|
||||
} elseif ($resp['status'] === 308) {
|
||||
continue;
|
||||
} else {
|
||||
fclose($fh);
|
||||
try {
|
||||
self::httpRequest($chunkUrl, 'DELETE', '', [
|
||||
'Authorization: Bearer ' . $token, 'Content-Length: 0',
|
||||
], 10);
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
$errJson = json_decode($resp['body'], true);
|
||||
$msg = $errJson['error'] ?? $errJson['detail'] ?? $resp['body'];
|
||||
throw new \RuntimeException('PeerTube chunk upload failed (' . $resp['status'] . '): ' . $msg);
|
||||
}
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
if (!$lastResponse) {
|
||||
throw new \RuntimeException('PeerTube upload: no completion response.');
|
||||
}
|
||||
|
||||
$finalJson = json_decode($lastResponse['body'], true);
|
||||
$shortUuid = $finalJson['video']['shortUUID'] ?? $finalJson['video']['uuid'] ?? null;
|
||||
if ($shortUuid === null) {
|
||||
throw new \RuntimeException('PeerTube upload: no video UUID in response.');
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
|
||||
$watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
|
||||
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', '', [
|
||||
'Authorization: Bearer ' . $token,
|
||||
], 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', '', [
|
||||
'Authorization: Bearer ' . $token,
|
||||
], 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', '', [], 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.
|
||||
*
|
||||
* @throws \RuntimeException on failure.
|
||||
*/
|
||||
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' => self::getClientId($s),
|
||||
'client_secret' => self::getClientSecret($s),
|
||||
'client_id' => $oauth['client_id'],
|
||||
'client_secret' => $oauth['client_secret'],
|
||||
'grant_type' => 'password',
|
||||
'response_type' => 'code',
|
||||
'username' => $s['username'],
|
||||
@@ -238,7 +424,6 @@ class PeerTubeService
|
||||
]);
|
||||
|
||||
$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);
|
||||
@@ -247,40 +432,16 @@ class PeerTubeService
|
||||
return $json['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the OAuth2 client_id from the PeerTube instance.
|
||||
*/
|
||||
private static function getClientId(array $s): string
|
||||
{
|
||||
return self::getOAuthClient($s)['client_id'];
|
||||
}
|
||||
|
||||
private static function getClientSecret(array $s): string
|
||||
{
|
||||
return self::getOAuthClient($s)['client_secret'];
|
||||
}
|
||||
|
||||
private static function getOAuthClient(array $s): array
|
||||
{
|
||||
$url = $s['instance_url'] . '/api/v1/oauth-clients/local';
|
||||
$response = self::httpRequest($url, 'GET', '', []);
|
||||
|
||||
$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'] . ').'
|
||||
);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Minimal cURL HTTP helper.
|
||||
*
|
||||
* @return array{status:int, body:string}
|
||||
*/
|
||||
private static function httpRequest(
|
||||
public static function httpRequest(
|
||||
string $url,
|
||||
string $method,
|
||||
string $body,
|
||||
@@ -306,12 +467,16 @@ class PeerTubeService
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
} elseif ($method === 'PUT') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
} elseif ($method === 'DELETE') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($responseBody === false) {
|
||||
throw new \RuntimeException('Erreur réseau PeerTube : ' . $error);
|
||||
|
||||
Reference in New Issue
Block a user