From 27151bd925ab0bfca754460478cb4eceea65a408 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 25 Jun 2026 19:59:51 -0300 Subject: [PATCH] fix(pinterest,youtube): default video cover and gate empty YouTube posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinterest video pins failed with "provide cover_image_url ... or cover_image_key_frame_time" because no cover was sent. Default to cover_image_key_frame_time 0 (first frame) when the user provides no cover_image_url — verified against Pinterest's OpenAPI spec (integer seconds, minimum 0, falls back to the last frame past the duration). YouTube Shorts derive their required title from the post content, so the publisher rejects an empty post — but the editor still let it be scheduled. Gate it in compliance: a youtube_short with empty content is now flagged (requires_text) so it can't be scheduled, instead of failing at publish. --- app/Services/Social/PinterestPublisher.php | 2 + lang/en/posts.php | 1 + lang/es/posts.php | 1 + lang/pt-BR/posts.php | 1 + resources/js/composables/usePostCompliance.ts | 9 +++ .../Social/PinterestPublisherTest.php | 56 ++++++++++++++++++- 6 files changed, 67 insertions(+), 3 deletions(-) diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 8ad9fc18..c0669c53 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -273,6 +273,8 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content): if (! empty(data_get($postPlatform->meta, 'cover_image_url'))) { $payload['media_source']['cover_image_url'] = data_get($postPlatform->meta, 'cover_image_url'); + } else { + $payload['media_source']['cover_image_key_frame_time'] = 0; } $response = $this->socialHttp()->withToken($account->access_token) diff --git a/lang/en/posts.php b/lang/en/posts.php index 6d6987f8..e4f52fed 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -353,6 +353,7 @@ 'document_too_large' => 'PDF exceeds the size limit for this platform.', 'aspect_ratio_invalid' => 'Aspect ratio is not supported by this format.', 'no_content_type' => 'Pick a content type for this platform.', + 'requires_text' => 'Add text — this format needs a title.', ], 'publishing' => 'Publishing...', 'publishing_overlay_title' => 'Your post is being published', diff --git a/lang/es/posts.php b/lang/es/posts.php index c4c3af01..61509fd8 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -353,6 +353,7 @@ 'document_too_large' => 'El PDF supera el límite de tamaño de esta plataforma.', 'aspect_ratio_invalid' => 'La proporción de aspecto no es compatible con este formato.', 'no_content_type' => 'Elige un tipo de contenido para esta plataforma.', + 'requires_text' => 'Agrega texto — este formato necesita un título.', ], 'publishing' => 'Publicando...', 'publishing_overlay_title' => 'Tu publicación se está enviando', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 1f0b03c5..72a8a361 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -353,6 +353,7 @@ 'document_too_large' => 'O PDF excede o limite de tamanho desta plataforma.', 'aspect_ratio_invalid' => 'A proporção da imagem não é suportada por este formato.', 'no_content_type' => 'Escolha um tipo de conteúdo para esta plataforma.', + 'requires_text' => 'Adicione texto — este formato precisa de um título.', ], 'publishing' => 'Publicando...', 'publishing_overlay_title' => 'Seu post está sendo publicado', diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index c2d4a6f7..0ee49eb6 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -30,6 +30,10 @@ export const PLATFORM_VARIANTS: Record = { [Platform.Pinterest]: [ContentType.PinterestPin, ContentType.PinterestVideoPin, ContentType.PinterestCarousel], }; +// Content types whose post needs text to publish — YouTube Shorts derive their +// required title from the post content, so an empty post can't be scheduled. +const CONTENT_TYPES_REQUIRING_TEXT = new Set([ContentType.YouTubeShort]); + type MetaRule = (meta: Record) => { valid: boolean; tooltipKey: string | null }; // Platforms whose `meta` blob has publish-time requirements. `valid` gates @@ -198,6 +202,11 @@ export const usePostCompliance = (opts: UsePostComplianceOptions) => { continue; } + if (CONTENT_TYPES_REQUIRING_TEXT.has(contentType) && content.value.trim() === '') { + issues[pp.id] = trans('posts.edit.compliance.requires_text'); + continue; + } + const reason = getMediaIncompatibilityReason(contentType, media.value); if (!reason) continue; diff --git a/tests/Feature/Services/Social/PinterestPublisherTest.php b/tests/Feature/Services/Social/PinterestPublisherTest.php index cacc62a9..7f727115 100644 --- a/tests/Feature/Services/Social/PinterestPublisherTest.php +++ b/tests/Feature/Services/Social/PinterestPublisherTest.php @@ -481,10 +481,60 @@ expect($result['id'])->toBe('video_pin_999'); expect($result['url'])->toBe('https://pinterest.com/pin/video_pin_999'); - Http::assertSent(function ($request) { - return str_contains($request->url(), '/v5/pins') - && data_get($request->data(), 'media_source.source_type') === 'video_id'; + Http::assertSent(fn ($request) => $request->url() === config('trypost.platforms.pinterest.api').'/pins' + && data_get($request->data(), 'media_source.source_type') === 'video_id' + && data_get($request->data(), 'media_source.cover_image_key_frame_time') === 0); +}); + +test('pinterest video pin uses the provided cover image url over the default frame', function () { + $this->postPlatform->update([ + 'content_type' => ContentType::PinterestVideoPin, + 'meta' => ['board_id' => 'board_123', 'cover_image_url' => 'https://example.com/cover.jpg'], + ]); + + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/video.mp4', + 'url' => 'https://example.com/media/2026-01/video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'video.mp4', + ]], + ]); + + $s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload'; + + Http::fake(function ($request) use ($s3UploadUrl) { + $url = $request->url(); + + if (str_contains($url, '/v5/media') && $request->method() === 'POST') { + return Http::response([ + 'media_id' => 'media_video_789', + 'upload_url' => $s3UploadUrl, + 'upload_parameters' => [], + ], 201); + } + + if ($url === $s3UploadUrl) { + return Http::response('', 204); + } + + if (str_contains($url, '/v5/media/media_video_789')) { + return Http::response(['status' => 'succeeded'], 200); + } + + if (str_contains($url, '/v5/pins')) { + return Http::response(['id' => 'video_pin_cover'], 200); + } + + return Http::response('fake-video-content', 200); }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => $request->url() === config('trypost.platforms.pinterest.api').'/pins' + && data_get($request->data(), 'media_source.cover_image_url') === 'https://example.com/cover.jpg' + && data_get($request->data(), 'media_source.cover_image_key_frame_time') === null); }); test('pinterest publisher throws exception for unsupported content type', function () {