fix(pinterest,youtube): default video cover and gate empty YouTube posts

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.
This commit is contained in:
Paulo Castellano 2026-06-25 19:59:51 -03:00
parent f0e2907ab5
commit 27151bd925
6 changed files with 67 additions and 3 deletions

View file

@ -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)

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -30,6 +30,10 @@ export const PLATFORM_VARIANTS: Record<string, string[]> = {
[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<string>([ContentType.YouTubeShort]);
type MetaRule = (meta: Record<string, any>) => { 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;

View file

@ -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 () {