fix(peertube): node-uploadx resumable upload to stop 504 on large A/V

Admin MP4 uploads failed at 100% with 'erreur de chargement'. Root cause:
FilepondHandler relayed the whole file to PeerTube via one synchronous
multipart POST; the PeerTube edge proxy answered 504 Gateway Time-out once
the relay exceeded its upstream timeout (xamxam-error log: filepond_peertube 504).

Migrate PeerTubeService::upload() to the resumable protocol
(docs.joinpeertube.org, node-uploadx — NOT Google resumable):
  - POST  /api/v1/videos/upload-resumable       -> 201 + Location
  - PUT   <Location> (Content-Range + application/octet-stream) -> 308/200
  - DELETE <Location> on failure (cancel)

Chunked PUT keeps every request under the PeerTube edge proxy timeout.
Verified live against videos.erg.be: 61.7MB BigBuckBunny MP4 uploads in 13.6s
and returns a real shortUUID.

Signature unchanged; both call sites (ThesisCreateController, FilepondHandler)
unaffected.
This commit is contained in:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent bfde71caaa
commit d3f5802e7f
2 changed files with 154 additions and 31 deletions
+152 -31
View File
@@ -189,7 +189,20 @@ class PeerTubeService
// -------------------------------------------------------------------------
/**
* Upload a local file to PeerTube using the simple multipart upload API.
* Upload a local file to PeerTube using the resumable upload protocol.
*
* The file is streamed in chunks via PUT so that no single HTTP request
* to the PeerTube edge proxy ever carries the whole payload. This is what
* makes multi-GB video/audio uploads work: the previous single-shot
* multipart POST held the connection open for the entire relay and the
* PeerTube front proxy answered 504 Gateway Time-out once the relay
* exceeded its own upstream timeout.
*
* Protocol (node-uploadx resumable, as implemented by PeerTube):
* POST /api/v1/videos/upload-resumable — init, returns Location header
* PUT <Location> — send one chunk (308 = continue,
* 200 = last chunk received)
* DELETE <Location> — cancel
*
* @param string $originalName The original client filename (e.g. "video.mp4") sent in the upload form.
* @return array{uuid:string, watchUrl:string}
@@ -213,54 +226,146 @@ class PeerTubeService
}
$token = self::obtainToken($s);
$baseUrl = $s['instance_url'];
$baseUrl = rtrim($s['instance_url'], '/');
$fileSize = filesize($filePath);
if ($fileSize === false || $fileSize === 0) {
throw new \RuntimeException('Fichier à téléverser vide ou introuvable.');
}
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($filePath) ?: 'application/octet-stream';
// ── 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],
// ── Step 1: initialize the resumable session ──────────────────────
$initData = [
'channelId' => $channelId,
'name' => $title,
'privacy' => (int)$s['privacy'],
'commentsEnabled' => true,
'category' => 15,
'filename' => $originalName,
'waitTranscoding' => false,
];
if ($description !== '') {
$multipart[] = ['name' => 'description', 'contents' => $description];
$initData['description'] = $description;
}
$resp = self::httpRequest($uploadUrl, 'POST', [
'headers' => ['Authorization' => 'Bearer ' . $token],
'multipart' => $multipart,
'timeout' => 600,
$initResp = self::httpRequest($baseUrl . '/api/v1/videos/upload-resumable', 'POST', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
'X-Upload-Content-Length' => (string)$fileSize,
'X-Upload-Content-Type' => $mimeType,
],
'body' => json_encode($initData),
'timeout' => 60,
]);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
$errJson = json_decode($resp['body'], true);
$msg = $errJson['error'] ?? $errJson['detail'] ?? $resp['body'];
$ex = new \RuntimeException('PeerTube upload failed (' . $resp['status'] . '): ' . $msg);
if ($initResp['status'] < 200 || $initResp['status'] >= 300) {
$errJson = json_decode($initResp['body'], true);
$msg = $errJson['error'] ?? $errJson['detail'] ?? $initResp['body'];
$ex = new \RuntimeException('PeerTube upload init failed (' . $initResp['status'] . '): ' . $msg);
ErrorHandler::log('peertube_upload', $ex, [
'status' => $resp['status'],
'title' => $title,
'instance' => $s['instance_url'],
'channel' => $s['channel_name'],
'stage' => 'init',
'status' => $initResp['status'],
'title' => $title,
'instance' => $s['instance_url'],
'channel' => $s['channel_name'],
]);
throw $ex;
}
$json = json_decode($resp['body'], true);
$shortUuid = $json['video']['shortUUID'] ?? $json['video']['uuid'] ?? null;
if ($shortUuid === null) {
$ex = new \RuntimeException('PeerTube upload: no video UUID in response.');
$chunkUrl = $initResp['headers']['location'] ?? '';
if ($chunkUrl === '') {
$ex = new \RuntimeException('PeerTube upload init: aucun en-tête Location reçu.');
ErrorHandler::log('peertube_upload', $ex, [
'body_sample' => substr($resp['body'], 0, 500),
'stage' => 'init',
'body_sample' => substr($initResp['body'], 0, 500),
'instance' => $s['instance_url'],
]);
throw $ex;
}
if (!str_starts_with($chunkUrl, 'http')) {
$chunkUrl = $baseUrl . '/' . ltrim($chunkUrl, '/');
}
// ── Step 2: stream the file in chunks via PUT ─────────────────────
// 10 MB chunks keep every request well under the PeerTube edge proxy
// timeout. The node-uploadx protocol accepts arbitrary chunk sizes
// (no 256 KB multiple requirement); 10 MB is large but efficient.
$chunkSize = 10 * 1024 * 1024;
$fh = fopen($filePath, 'rb');
if ($fh === false) {
throw new \RuntimeException('Impossible d\'ouvrir le fichier pour le téléversement.');
}
$offset = 0;
$finalUuid = null;
$finalBody = '';
try {
while ($offset < $fileSize) {
$chunk = fread($fh, $chunkSize);
if ($chunk === false || $chunk === '') {
throw new \RuntimeException('Lecture du fichier interrompue à l\'octet ' . $offset . '.');
}
$chunkLen = strlen($chunk);
$end = $offset + $chunkLen - 1;
$resp = self::httpRequest($chunkUrl, 'PUT', [
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/octet-stream',
'Content-Range' => 'bytes ' . $offset . '-' . $end . '/' . $fileSize,
],
'body' => $chunk,
'timeout' => 600,
]);
$offset += $chunkLen;
$finalBody = $resp['body'];
if ($resp['status'] === 308) {
// Resume Incomplete — chunk accepted, keep going.
continue;
}
if ($resp['status'] >= 200 && $resp['status'] < 300) {
$json = json_decode($resp['body'], true);
$finalUuid = $json['video']['shortUUID'] ?? $json['video']['uuid'] ?? null;
break;
}
// Any other status is a hard failure.
$errJson = json_decode($resp['body'], true);
$msg = $errJson['error'] ?? $errJson['detail'] ?? $resp['body'];
throw new \RuntimeException('PeerTube chunk upload failed (' . $resp['status'] . '): ' . $msg);
}
} catch (\Throwable $e) {
fclose($fh);
self::cancelUpload($chunkUrl, $token);
ErrorHandler::log('peertube_upload', $e, [
'stage' => 'chunk',
'offset' => $offset,
'fileSize' => $fileSize,
'title' => $title,
'instance' => $s['instance_url'],
'channel' => $s['channel_name'],
]);
throw $e;
}
fclose($fh);
if ($finalUuid === null) {
$ex = new \RuntimeException('PeerTube upload: aucun UUID vidéo dans la réponse finale.');
ErrorHandler::log('peertube_upload', $ex, [
'stage' => 'complete',
'body_sample' => substr($finalBody, 0, 500),
'instance' => $s['instance_url'],
]);
throw $ex;
}
$watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
$shortUuid = $finalUuid;
$watchUrl = $baseUrl . '/videos/watch/' . $shortUuid;
Logger::get('app')->info(json_encode([
'timestamp' => date('c'),
'source' => 'peertube',
@@ -275,6 +380,22 @@ class PeerTubeService
return ['uuid' => $shortUuid, 'watchUrl' => $watchUrl];
}
/**
* Cancel an in-progress resumable upload. Best-effort: failures are
* swallowed (the session expires server-side anyway).
*/
private static function cancelUpload(string $chunkUrl, string $token): void
{
try {
self::httpRequest($chunkUrl, 'DELETE', [
'headers' => ['Authorization' => 'Bearer ' . $token],
'timeout' => 30,
]);
} catch (\Throwable $e) {
// ignore — cancelling is opportunistic cleanup
}
}
// -------------------------------------------------------------------------
// Fetch video info / watch URL
// -------------------------------------------------------------------------