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] #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] #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] #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
|
## 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
|
- [ ] #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.
|
* @param string $originalName The original client filename (e.g. "video.mp4") sent in the upload form.
|
||||||
* @return array{uuid:string, watchUrl:string}
|
* @return array{uuid:string, watchUrl:string}
|
||||||
@@ -213,35 +226,45 @@ class PeerTubeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$token = self::obtainToken($s);
|
$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) ──
|
// ── Step 1: initialize the resumable session ──────────────────────
|
||||||
$uploadUrl = $baseUrl . '/api/v1/videos/upload';
|
$initData = [
|
||||||
|
'channelId' => $channelId,
|
||||||
$multipart = [
|
'name' => $title,
|
||||||
['name' => 'channelId', 'contents' => $channelId],
|
'privacy' => (int)$s['privacy'],
|
||||||
['name' => 'name', 'contents' => $title],
|
'commentsEnabled' => true,
|
||||||
['name' => 'privacy', 'contents' => (int)$s['privacy']],
|
'category' => 15,
|
||||||
['name' => 'commentsEnabled', 'contents' => 'true'],
|
'filename' => $originalName,
|
||||||
['name' => 'category', 'contents' => '15'],
|
'waitTranscoding' => false,
|
||||||
['name' => 'videofile', 'contents' => fopen($filePath, 'r'), 'filename' => $originalName],
|
|
||||||
];
|
];
|
||||||
if ($description !== '') {
|
if ($description !== '') {
|
||||||
$multipart[] = ['name' => 'description', 'contents' => $description];
|
$initData['description'] = $description;
|
||||||
}
|
}
|
||||||
|
|
||||||
$resp = self::httpRequest($uploadUrl, 'POST', [
|
$initResp = self::httpRequest($baseUrl . '/api/v1/videos/upload-resumable', 'POST', [
|
||||||
'headers' => ['Authorization' => 'Bearer ' . $token],
|
'headers' => [
|
||||||
'multipart' => $multipart,
|
'Authorization' => 'Bearer ' . $token,
|
||||||
'timeout' => 600,
|
'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) {
|
if ($initResp['status'] < 200 || $initResp['status'] >= 300) {
|
||||||
$errJson = json_decode($resp['body'], true);
|
$errJson = json_decode($initResp['body'], true);
|
||||||
$msg = $errJson['error'] ?? $errJson['detail'] ?? $resp['body'];
|
$msg = $errJson['error'] ?? $errJson['detail'] ?? $initResp['body'];
|
||||||
$ex = new \RuntimeException('PeerTube upload failed (' . $resp['status'] . '): ' . $msg);
|
$ex = new \RuntimeException('PeerTube upload init failed (' . $initResp['status'] . '): ' . $msg);
|
||||||
ErrorHandler::log('peertube_upload', $ex, [
|
ErrorHandler::log('peertube_upload', $ex, [
|
||||||
'status' => $resp['status'],
|
'stage' => 'init',
|
||||||
|
'status' => $initResp['status'],
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'instance' => $s['instance_url'],
|
'instance' => $s['instance_url'],
|
||||||
'channel' => $s['channel_name'],
|
'channel' => $s['channel_name'],
|
||||||
@@ -249,18 +272,100 @@ class PeerTubeService
|
|||||||
throw $ex;
|
throw $ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
$json = json_decode($resp['body'], true);
|
$chunkUrl = $initResp['headers']['location'] ?? '';
|
||||||
$shortUuid = $json['video']['shortUUID'] ?? $json['video']['uuid'] ?? null;
|
if ($chunkUrl === '') {
|
||||||
if ($shortUuid === null) {
|
$ex = new \RuntimeException('PeerTube upload init: aucun en-tête Location reçu.');
|
||||||
$ex = new \RuntimeException('PeerTube upload: no video UUID in response.');
|
|
||||||
ErrorHandler::log('peertube_upload', $ex, [
|
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'],
|
'instance' => $s['instance_url'],
|
||||||
]);
|
]);
|
||||||
throw $ex;
|
throw $ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
$watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
|
$shortUuid = $finalUuid;
|
||||||
|
$watchUrl = $baseUrl . '/videos/watch/' . $shortUuid;
|
||||||
Logger::get('app')->info(json_encode([
|
Logger::get('app')->info(json_encode([
|
||||||
'timestamp' => date('c'),
|
'timestamp' => date('c'),
|
||||||
'source' => 'peertube',
|
'source' => 'peertube',
|
||||||
@@ -275,6 +380,22 @@ class PeerTubeService
|
|||||||
return ['uuid' => $shortUuid, 'watchUrl' => $watchUrl];
|
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
|
// Fetch video info / watch URL
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user