mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
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:
@@ -63,6 +63,8 @@
|
||||
- [x] #decouple-rotation-interval-30min Decouple rotation interval (30min) from idle timeout constant
|
||||
- [x] #update-docs-security-md-deploy-server-sh Update docs/security.md + deploy-server.sh comment for new idle value
|
||||
- [x] #extend-smoke-test-for Extend smoke test for rotation and 4h idle boundary
|
||||
- [x] #fix-14-biome-check Fix 14 biome check errors: unsorted imports in scripts/css-*.mjs + unformatted CSS/JS files
|
||||
- [x] #fix-peertube-resumable-chunked-upload [!high] fix(peertube): resumable chunked upload to stop 504 on large A/V — Admin MP4 upload fails at 100%: PHP relays whole file to PeerTube via one blocking multipart POST; PeerTube edge nginx returns 504 Gateway Time-out (see xamxam-error log filepond_peertube). Migrate PeerTubeService::upload() to resumable upload protocol with chunked PATCH so no single request exceeds the proxy timeout.
|
||||
|
||||
## Deferred / Blocked
|
||||
- [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal
|
||||
|
||||
+149
-28
@@ -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,35 +226,45 @@ 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'],
|
||||
'stage' => 'init',
|
||||
'status' => $initResp['status'],
|
||||
'title' => $title,
|
||||
'instance' => $s['instance_url'],
|
||||
'channel' => $s['channel_name'],
|
||||
@@ -249,18 +272,100 @@ class PeerTubeService
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user