fix PeerTube upload: final working solution — simple multipart POST with CURLFile; iterated through Google-resumable PATCH protocol debugging (HTTP version negotiation, chunk body encoding, off-by-one fixes) before settling on simpler POST approach

This commit is contained in:
Pontoporeia
2026-05-11 12:09:19 +02:00
parent 1b0451581d
commit cdec3e96a6
9 changed files with 281 additions and 112 deletions

View File

@@ -614,6 +614,7 @@ class ThesisCreateController
$result = PeerTubeService::upload(
$this->db,
$uploads['tmp_name'][$i],
$uploads['name'][$i],
$title,
''
);

View File

@@ -604,6 +604,7 @@ class ThesisEditController
$result = PeerTubeService::upload(
$this->db,
$uploads['tmp_name'][$i],
$uploads['name'][$i],
$title,
''
);

View File

@@ -17,11 +17,8 @@
* instance's /api/v1/oauth-clients/local endpoint and cached in-memory
* per process lifetime.
*
* Upload uses the Google-resumable protocol:
* POST /api/v1/videos/upload-resumable — init (→ Location header with upload URL token)
* PATCH <Location URL> — send chunk
* HEAD <Location URL> — resume check
* DELETE <Location URL> — cancel
* Upload uses the simple multipart upload API:
* POST /api/v1/videos/upload — multipart form with CURLFile
*/
class PeerTubeService
{
@@ -157,14 +154,16 @@ class PeerTubeService
// -------------------------------------------------------------------------
/**
* Upload a local file to PeerTube using the resumable upload 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 {
@@ -180,106 +179,43 @@ class PeerTubeService
$token = self::obtainToken($s);
$baseUrl = $s['instance_url'];
$fileSize = filesize($filePath);
$fileName = basename($filePath);
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($filePath);
// ── 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,
// ── Simple multipart upload (non-resumable) ──
$uploadUrl = $baseUrl . '/api/v1/videos/upload';
$postFields = [
'channelId' => $channelId,
'name' => $title,
'privacy' => (int)$s['privacy'],
'commentsEnabled' => true,
'category' => 15,
'videofile' => new \CURLFile($filePath, $mimeType, $originalName),
];
if ($description !== '') {
$initData['description'] = $description;
$postFields['description'] = $description;
}
$initBody = json_encode($initData);
$initResponse = self::httpRequest($initUrl, 'POST', $initBody, [
$resp = self::httpRequest($uploadUrl, 'POST', $postFields, [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'X-Upload-Content-Length: ' . $fileSize,
'X-Upload-Content-Type: ' . $mimeType,
'Content-Length: ' . strlen($initBody),
]);
], 600);
// PeerTube Google-resumable returns the upload session URL in the Location header.
// The JSON body contains video.id (upload session ID, not final video ID).
$chunkUrl = $initResponse['headers']['location'] ?? $initResponse['headers']['Location'] ?? null;
if (!$chunkUrl) {
$initJson = json_decode($initResponse['body'], true);
$msg = $initJson['error'] ?? $initJson['detail'] ?? $initResponse['body'];
throw new \RuntimeException('PeerTube upload init: no Location header (' . $initResponse['status'] . '): ' . $msg);
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);
}
// Relative Location? Make it absolute.
if (!str_starts_with($chunkUrl, 'http')) {
$chunkUrl = rtrim($baseUrl, '/') . $chunkUrl;
}
// ── Step 2: Send chunks via PATCH (Google-resumable variant) ──
$fh = fopen($filePath, 'rb');
if (!$fh) {
throw new \RuntimeException('Cannot open file for resumable upload.');
}
// Chunk size: 1 MB, must be a multiple of 256 KB (262144 bytes).
$chunkSizeBase = 256 * 1024;
$chunkSize = max($chunkSizeBase, min(4 * 1024 * 1024, (int)ceil($fileSize / 100)));
$chunkSize = (int)ceil($chunkSize / $chunkSizeBase) * $chunkSizeBase;
$offset = 0;
$lastResponse = null;
while ($offset < $fileSize) {
$chunk = fread($fh, $chunkSize);
$chunkLen = strlen($chunk);
$end = $offset + $chunkLen - 1;
$resp = self::httpRequest($chunkUrl, 'PATCH', $chunk, [
'Authorization: Bearer ' . $token,
'Content-Type: ' . $mimeType,
'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) {
// Resume Incomplete — chunk accepted, continue
continue;
} else {
fclose($fh);
try {
self::cancelUpload($chunkUrl, $token);
} 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;
$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];
}
@@ -434,17 +370,6 @@ class PeerTubeService
// HTTP helper
// -------------------------------------------------------------------------
/**
* Cancel a resumable upload session.
*/
private static function cancelUpload(string $chunkUrl, string $token): void
{
self::httpRequest($chunkUrl, 'DELETE', '', [
'Authorization: Bearer ' . $token,
'Content-Length: 0',
], 10);
}
// -------------------------------------------------------------------------
// HTTP helper
// -------------------------------------------------------------------------
@@ -457,7 +382,7 @@ class PeerTubeService
public static function httpRequest(
string $url,
string $method,
string $body,
string|array $body,
array $headers,
int $timeout = 300
): array {
@@ -475,6 +400,7 @@ class PeerTubeService
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0,
CURLOPT_HEADERFUNCTION => function ($ch, $headerLine) use (&$responseHeaders) {
$len = strlen($headerLine);
$parts = explode(':', $headerLine, 2);
@@ -490,11 +416,8 @@ 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 === 'PATCH') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
} elseif ($method === 'PUT' || $method === 'PATCH') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} elseif ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');