diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 8fc052cc..16c8f7d2 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -370,6 +370,9 @@ private function doUploadVideo(string $tempFile, $mediaItem): ?string if ($etag) { $uploadedPartIds[] = $etag; } + + unset($chunkData, $chunkResponse); + $this->freeChunkMemory(); } } finally { fclose($handle); diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index ed9cd0f8..a465b97a 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -41,4 +41,16 @@ protected function redactResponseBody(string $body): string { return TokenRedactor::redact($body); } + + /** + * Reclaim memory between chunks of a streaming upload. Each request's body + * and the HTTP client's request/response objects are held alive by reference + * cycles the PHP runtime only frees when the cycle collector runs, so a + * long chunked upload accumulates the whole file in memory without this. + * Callers must `unset()` the chunk and response first. + */ + protected function freeChunkMemory(): void + { + gc_collect_cycles(); + } } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index bde45d74..f7be5127 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -237,11 +237,8 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy $index++; - // The HTTP client/retry wrapper keeps each segment's body alive - // through reference cycles, so without forcing collection a large - // video accumulates its full size in memory and exhausts the limit. unset($chunk, $appendResponse); - gc_collect_cycles(); + $this->freeChunkMemory(); } } finally { fclose($handle); diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 5b4467c5..b4f1496b 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -167,6 +167,9 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount while (! $uploadStatus && ! feof($handle)) { $chunk = fread($handle, self::CHUNK_SIZE); $uploadStatus = $mediaUpload->nextChunk($chunk); + + unset($chunk); + $this->freeChunkMemory(); } fclose($handle); diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index f0fe2a3f..94b837cc 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -431,6 +431,75 @@ Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts')); }); +test('linkedin publisher uploads a video across multiple chunks', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ], + ], + ]); + + $chunkBase = 'https://www.linkedin.com/dms/upload/v2/chunk/video/'; + $chunkPuts = 0; + + Http::fake(function ($request) use ($chunkBase, &$chunkPuts) { + $url = $request->url(); + + if (str_contains($url, 'initializeUpload') && str_contains($url, '/rest/videos')) { + return Http::response([ + 'value' => [ + 'video' => 'urn:li:video:FakeVideoUrn', + 'uploadToken' => 'upload-token-abc', + 'uploadInstructions' => [ + ['uploadUrl' => $chunkBase.'0', 'firstByte' => 0, 'lastByte' => 1023], + ['uploadUrl' => $chunkBase.'1', 'firstByte' => 1024, 'lastByte' => 2047], + ['uploadUrl' => $chunkBase.'2', 'firstByte' => 2048, 'lastByte' => 3071], + ], + ], + ], 200); + } + + if (str_starts_with($url, $chunkBase)) { + $chunkPuts++; + + return Http::response(null, 200, ['etag' => '"etag-'.$chunkPuts.'"']); + } + + if (str_contains($url, 'finalizeUpload') && str_contains($url, '/rest/videos')) { + return Http::response(null, 200); + } + + if (str_contains($url, '/rest/videos/')) { + return Http::response(['status' => 'AVAILABLE'], 200); + } + + if (str_contains($url, '/rest/posts')) { + return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:multichunk']); + } + + return Http::response(str_repeat('x', 4096), 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('urn:li:share:multichunk'); + expect($chunkPuts)->toBe(3); + + // All three chunk etags are collected and sent to finalize. + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'finalizeUpload')) { + return false; + } + + return count(data_get($request->data(), 'finalizeUploadRequest.uploadedPartIds', [])) === 3; + }); +}); + test('linkedin publisher can publish a document (pdf carousel) with a title', function () { $this->postPlatform->update([ 'content_type' => ContentType::LinkedInPost,