diff --git a/TODO.md b/TODO.md index 41e5401..0dce3b2 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/app/src/PeerTubeService.php b/app/src/PeerTubeService.php index 9077ff4..ff3562d 100644 --- a/app/src/PeerTubeService.php +++ b/app/src/PeerTubeService.php @@ -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 — send one chunk (308 = continue, + * 200 = last chunk received) + * DELETE — 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 // -------------------------------------------------------------------------