fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields (content_type was silently dropped, causing Instagram Reels to post as Feed) - Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation - Fix syntax errors in all publishers ($media->isVideo() missing variable) - Fix Instagram Feed with single video calling publishSingleImage instead of publishReel - Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API - Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload - Fix all publishers using file_get_contents for large videos (memory overflow) — X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream - Fix Media::isVideo/isImage to use mime_type instead of extension - Fix Threads not saving refresh_token (was null, now saves access_token) - Add Instagram token refresh to publisher and ConnectionVerifier - Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads), timeout 60→600s, added failed() method for cleanup - Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s - Increase upload limit 500MB→1GB - Add mastodon to getDefaultContentType in Edit.vue
This commit is contained in:
parent
bb6d0a2678
commit
9f3b8e547a
30 changed files with 870 additions and 234 deletions
|
|
@ -8,6 +8,8 @@
|
|||
use App\Actions\Post\DeletePost;
|
||||
use App\Actions\Post\UpdatePost;
|
||||
use App\Enums\Post\Action as PostAction;
|
||||
use App\Http\Requests\Api\Post\StorePostRequest;
|
||||
use App\Http\Requests\Api\Post\UpdatePostRequest;
|
||||
use App\Http\Resources\Api\PostResource;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -38,21 +40,12 @@ public function show(Request $request, Post $post): PostResource
|
|||
return new PostResource($post);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
public function store(StorePostRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'platforms' => ['required', 'array', 'min:1'],
|
||||
'platforms.*.social_account_id' => ['required', 'uuid'],
|
||||
'platforms.*.content_type' => ['required', 'string'],
|
||||
'platforms.*.content' => ['nullable', 'string'],
|
||||
'scheduled_at' => ['nullable', 'date', 'after:now'],
|
||||
'status' => ['nullable', 'string', 'in:draft,scheduled,publishing'],
|
||||
]);
|
||||
|
||||
$post = CreatePost::execute(
|
||||
$request->workspace,
|
||||
$request->workspace->owner,
|
||||
$validated
|
||||
$request->validated()
|
||||
);
|
||||
|
||||
$post->load(['postPlatforms.socialAccount']);
|
||||
|
|
@ -62,23 +55,13 @@ public function store(Request $request): JsonResponse
|
|||
->setStatusCode(Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function update(Request $request, Post $post): PostResource|JsonResponse
|
||||
public function update(UpdatePostRequest $request, Post $post): PostResource|JsonResponse
|
||||
{
|
||||
if ($post->workspace_id !== $request->workspace->id) {
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'platforms' => ['sometimes', 'array'],
|
||||
'platforms.*.id' => ['required', 'uuid'],
|
||||
'platforms.*.content' => ['nullable', 'string'],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
'status' => ['nullable', 'string', 'in:draft,scheduled,publishing'],
|
||||
'label_ids' => ['sometimes', 'array'],
|
||||
'label_ids.*' => ['uuid'],
|
||||
]);
|
||||
|
||||
$result = UpdatePost::execute($request->workspace, $post, $validated);
|
||||
$result = UpdatePost::execute($request->workspace, $post, $request->validated());
|
||||
|
||||
if (data_get($result, 'action') === PostAction::AlreadyPublished) {
|
||||
return response()->json(
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ public function callback(Request $request): View
|
|||
'display_name' => data_get($profile, 'name', data_get($profile, 'username')),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $longLivedToken,
|
||||
'refresh_token' => null,
|
||||
'refresh_token' => $longLivedToken,
|
||||
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
||||
'scopes' => $this->scopes,
|
||||
]);
|
||||
|
|
|
|||
30
app/Http/Requests/Api/Post/StorePostRequest.php
Normal file
30
app/Http/Requests/Api/Post/StorePostRequest.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\Post;
|
||||
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StorePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'platforms' => ['required', 'array', 'min:1'],
|
||||
'platforms.*.social_account_id' => ['required', 'uuid'],
|
||||
'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
|
||||
'platforms.*.content' => ['nullable', 'string', 'max:63206'],
|
||||
'scheduled_at' => ['nullable', 'date', 'after:now'],
|
||||
'status' => ['nullable', 'string', Rule::in(array_column(Status::cases(), 'value'))],
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/Api/Post/UpdatePostRequest.php
Normal file
34
app/Http/Requests/Api/Post/UpdatePostRequest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\Post;
|
||||
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', 'string', Rule::in(array_column(Status::cases(), 'value'))],
|
||||
'synced' => ['required', 'boolean'],
|
||||
'platforms' => ['required', 'array'],
|
||||
'platforms.*.id' => ['required', 'uuid'],
|
||||
'platforms.*.content' => ['nullable', 'string', 'max:63206'],
|
||||
'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
|
||||
'platforms.*.meta' => ['nullable', 'array'],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
'label_ids' => ['sometimes', 'array'],
|
||||
'label_ids.*' => ['uuid'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ public function rules(): array
|
|||
'media' => [
|
||||
'required',
|
||||
'file',
|
||||
'max:512000', // 500MB
|
||||
'max:1048576', // 1GB
|
||||
'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/webm,application/pdf',
|
||||
],
|
||||
'model' => [
|
||||
|
|
@ -50,7 +50,7 @@ public function messages(): array
|
|||
{
|
||||
return [
|
||||
'media.required' => 'The file is required.',
|
||||
'media.max' => 'The file must be at most 500MB.',
|
||||
'media.max' => 'The file must be at most 1GB.',
|
||||
'media.mimetypes' => 'File type not supported.',
|
||||
'model.required' => 'The model is required.',
|
||||
'model.in' => 'Invalid model type.',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
namespace App\Http\Requests\App\Post;
|
||||
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
|
|
@ -17,11 +19,14 @@ public function authorize(): bool
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['sometimes', 'string'],
|
||||
'status' => ['required', 'string', Rule::in(array_column(Status::cases(), 'value'))],
|
||||
'synced' => ['required', 'boolean'],
|
||||
'scheduled_at' => ['sometimes', 'nullable', 'string'],
|
||||
'platforms' => ['sometimes', 'array'],
|
||||
'platforms' => ['required', 'array'],
|
||||
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id ?? $this->route('post'))],
|
||||
'platforms.*.content' => ['nullable', 'string', 'max:5000'],
|
||||
'platforms.*.content' => ['nullable', 'string', 'max:63206'],
|
||||
'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value'))],
|
||||
'platforms.*.meta' => ['nullable', 'array'],
|
||||
'label_ids' => ['sometimes', 'array'],
|
||||
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)],
|
||||
];
|
||||
|
|
@ -30,6 +35,12 @@ public function rules(): array
|
|||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'status.required' => 'The post status is required.',
|
||||
'status.in' => 'Invalid post status.',
|
||||
'synced.required' => 'The synced field is required.',
|
||||
'platforms.required' => 'At least one platform is required.',
|
||||
'platforms.*.content_type.required' => 'The content type is required for each platform.',
|
||||
'platforms.*.content_type.in' => 'Invalid content type.',
|
||||
'scheduled_at.after' => 'The scheduled date must be in the future.',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,14 +33,21 @@ class PublishToSocialPlatform implements ShouldQueue
|
|||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $tries = 1;
|
||||
|
||||
public int $backoff = 60;
|
||||
public int $timeout = 600; // 10 minutes — large video uploads need time
|
||||
|
||||
public function __construct(public PostPlatform $postPlatform) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Idempotency: skip if already published (prevents duplicate posts on retry)
|
||||
$this->postPlatform->refresh();
|
||||
|
||||
if ($this->postPlatform->status === PostPlatformStatus::Published) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->postPlatform->socialAccount->is_active) {
|
||||
$this->postPlatform->markAsFailed(__('posts.errors.account_inactive'));
|
||||
$this->updatePostStatus();
|
||||
|
|
@ -169,6 +176,23 @@ private function notifySuccess(Post $post): void
|
|||
);
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
Log::error('PublishToSocialPlatform job failed permanently', [
|
||||
'post_platform_id' => $this->postPlatform->id,
|
||||
'platform' => $this->postPlatform->platform->value,
|
||||
'error' => $exception?->getMessage(),
|
||||
]);
|
||||
|
||||
$this->postPlatform->refresh();
|
||||
|
||||
if ($this->postPlatform->status !== PostPlatformStatus::Published) {
|
||||
$this->postPlatform->markAsFailed($exception?->getMessage() ?? 'Unknown error');
|
||||
$this->updatePostStatus();
|
||||
$this->broadcastStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyFailure(Post $post): void
|
||||
{
|
||||
$owner = $post->workspace->owner;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,28 @@ protected function url(): Attribute
|
|||
);
|
||||
}
|
||||
|
||||
public function isVideo(): bool
|
||||
{
|
||||
if ($this->mime_type) {
|
||||
return str_starts_with($this->mime_type, 'video/');
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($extension, ['mp4', 'mov', 'avi', 'wmv', 'webm', 'mkv', 'm4v']);
|
||||
}
|
||||
|
||||
public function isImage(): bool
|
||||
{
|
||||
if ($this->mime_type) {
|
||||
return str_starts_with($this->mime_type, 'image/');
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']);
|
||||
}
|
||||
|
||||
public function getTemporaryUrl(int $expirationMinutes = 60): string
|
||||
{
|
||||
return Storage::temporaryUrl(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
if ($medias->count() > 0) {
|
||||
$images = [];
|
||||
foreach ($medias->take(4) as $media) {
|
||||
if (str_starts_with($media->mime_type, 'image/')) {
|
||||
if ($media->isImage()) {
|
||||
$blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type);
|
||||
if ($blob) {
|
||||
$images[] = [
|
||||
|
|
@ -110,29 +110,38 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array
|
||||
{
|
||||
try {
|
||||
$imageContent = file_get_contents($url);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'bsky_blob_');
|
||||
|
||||
if ($imageContent === false) {
|
||||
Log::error('Bluesky failed to read image', ['url' => $url]);
|
||||
try {
|
||||
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url);
|
||||
|
||||
$fileSize = filesize($tempFile);
|
||||
|
||||
if ($fileSize === false || $fileSize === 0) {
|
||||
Log::error('Bluesky failed to download media', ['url' => $url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bluesky has 1MB limit for images
|
||||
if (strlen($imageContent) > 1000000) {
|
||||
if (str_starts_with($mimeType, 'image/') && $fileSize > 1000000) {
|
||||
Log::warning('Bluesky image exceeds 1MB limit', [
|
||||
'size' => strlen($imageContent),
|
||||
'size' => $fileSize,
|
||||
'url' => $url,
|
||||
]);
|
||||
// TODO: Resize image if needed
|
||||
}
|
||||
|
||||
$stream = fopen($tempFile, 'r');
|
||||
|
||||
$response = Http::withToken($account->access_token)
|
||||
->withHeaders(['Content-Type' => $mimeType])
|
||||
->withBody($imageContent, $mimeType)
|
||||
->withBody($stream, $mimeType)
|
||||
->post("{$service}/xrpc/com.atproto.repo.uploadBlob");
|
||||
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Bluesky blob upload failed', [
|
||||
'status' => $response->status(),
|
||||
|
|
@ -150,6 +159,8 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
|
|||
]);
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ private function refreshTokenIfNeeded(SocialAccount $account): void
|
|||
Platform::TikTok => $this->refreshTikTokToken($account),
|
||||
Platform::Pinterest => $this->refreshPinterestToken($account),
|
||||
Platform::Threads => $this->refreshThreadsToken($account),
|
||||
// Facebook, Instagram use long-lived tokens without refresh mechanism
|
||||
Platform::Instagram => $this->refreshInstagramToken($account),
|
||||
// Facebook uses page tokens that don't expire
|
||||
// Mastodon tokens don't expire
|
||||
default => null,
|
||||
};
|
||||
|
|
@ -278,9 +279,35 @@ private function refreshThreadsToken(SocialAccount $account): void
|
|||
}
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
||||
$account->update([
|
||||
'access_token' => data_get($data, 'access_token'),
|
||||
'access_token' => $newToken,
|
||||
'refresh_token' => $newToken,
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
]);
|
||||
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
private function refreshInstagramToken(SocialAccount $account): void
|
||||
{
|
||||
$response = Http::get('https://graph.instagram.com/refresh_access_token', [
|
||||
'grant_type' => 'ig_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $response->body()]);
|
||||
throw new TokenExpiredException('Failed to refresh Instagram token');
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
||||
$account->update([
|
||||
'access_token' => $newToken,
|
||||
'refresh_token' => $newToken,
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
|
|||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
$isImage = str_starts_with($firstMedia->mime_type, 'image/');
|
||||
$isVideo = $firstMedia->isVideo();
|
||||
$isImage = $firstMedia->isImage();
|
||||
|
||||
if ($isVideo) {
|
||||
return $this->publishVideoPost($pageId, $accessToken, $content, $firstMedia);
|
||||
|
|
@ -145,7 +145,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
|
|||
$attachedMedia = [];
|
||||
|
||||
foreach ($mediaCollection as $media) {
|
||||
if (! str_starts_with($media->mime_type, 'image/')) {
|
||||
if (! $media->isImage()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -292,7 +292,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
|
|||
{
|
||||
Log::info('Facebook publishing story', ['page_id' => $pageId]);
|
||||
|
||||
$isVideo = str_starts_with($media->mime_type, 'video/');
|
||||
$isVideo = $media->isVideo();
|
||||
|
||||
if ($isVideo) {
|
||||
// Video story
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -36,6 +37,12 @@ class InstagramPublisher
|
|||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
$this->refreshToken($account);
|
||||
$account->refresh();
|
||||
}
|
||||
|
||||
$instagramId = $account->platform_user_id;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
|
|
@ -51,13 +58,26 @@ public function publish(PostPlatform $postPlatform): array
|
|||
return match ($contentType) {
|
||||
ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $postPlatform->content, $firstMedia),
|
||||
ContentType::InstagramStory => $this->publishStory($instagramId, $accessToken, $firstMedia),
|
||||
ContentType::InstagramFeed => $media->count() > 1
|
||||
? $this->publishCarousel($instagramId, $accessToken, $postPlatform->content, $media)
|
||||
: $this->publishSingleImage($instagramId, $accessToken, $postPlatform->content, $firstMedia),
|
||||
ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $postPlatform->content, $media),
|
||||
default => throw new \Exception("Unsupported Instagram content type: {$contentType?->value}"),
|
||||
};
|
||||
}
|
||||
|
||||
private function publishFeed(string $instagramId, string $accessToken, ?string $content, $media): array
|
||||
{
|
||||
if ($media->count() > 1) {
|
||||
return $this->publishCarousel($instagramId, $accessToken, $content, $media);
|
||||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
|
||||
if ($firstMedia->isVideo()) {
|
||||
return $this->publishReel($instagramId, $accessToken, $content, $firstMedia);
|
||||
}
|
||||
|
||||
return $this->publishSingleImage($instagramId, $accessToken, $content, $firstMedia);
|
||||
}
|
||||
|
||||
private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array
|
||||
{
|
||||
Log::info('Instagram publishing single image', ['instagram_id' => $instagramId, 'image_url' => $media->url]);
|
||||
|
|
@ -132,7 +152,7 @@ private function publishStory(string $instagramId, string $accessToken, $media):
|
|||
{
|
||||
Log::info('Instagram publishing story', ['instagram_id' => $instagramId]);
|
||||
|
||||
$isVideo = str_starts_with($media->mime_type, 'video/');
|
||||
$isVideo = $media->isVideo();
|
||||
|
||||
$params = [
|
||||
'media_type' => 'STORIES',
|
||||
|
|
@ -180,7 +200,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
|
|||
$childContainers = [];
|
||||
|
||||
foreach ($mediaCollection as $media) {
|
||||
$isVideo = str_starts_with($media->mime_type, 'video/');
|
||||
$isVideo = $media->isVideo();
|
||||
|
||||
$params = [
|
||||
'is_carousel_item' => 'true',
|
||||
|
|
@ -311,6 +331,31 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
|
|||
Log::warning('Instagram media processing timeout, proceeding anyway');
|
||||
}
|
||||
|
||||
private function refreshToken(SocialAccount $account): void
|
||||
{
|
||||
$response = Http::get('https://graph.instagram.com/refresh_access_token', [
|
||||
'grant_type' => 'ig_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Instagram token refresh failed', ['body' => $response->body()]);
|
||||
|
||||
throw new TokenExpiredException('Failed to refresh Instagram token: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
||||
$account->update([
|
||||
'access_token' => $newToken,
|
||||
'refresh_token' => $newToken,
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
]);
|
||||
|
||||
Log::info('Instagram token refreshed successfully');
|
||||
}
|
||||
|
||||
private function handleApiError(Response $response, string $context): void
|
||||
{
|
||||
$body = $response->json() ?? [];
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med
|
|||
$carouselItems = [];
|
||||
|
||||
foreach ($mediaCollection as $media) {
|
||||
if (! str_starts_with($media->mime_type, 'image/')) {
|
||||
if (! $media->isImage()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -307,8 +307,20 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
|
|||
|
||||
private function uploadVideo($mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
$videoContent = file_get_contents($mediaItem->url);
|
||||
$fileSize = strlen($videoContent);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'lip_video_');
|
||||
|
||||
try {
|
||||
return $this->doUploadVideo($tempFile, $mediaItem, $ownerUrn);
|
||||
} finally {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
|
||||
|
||||
$fileSize = (int) filesize($tempFile);
|
||||
|
||||
Log::info('LinkedIn Page initializing video upload', [
|
||||
'owner' => $ownerUrn,
|
||||
|
|
@ -353,7 +365,11 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string
|
|||
$firstByte = $instruction['firstByte'];
|
||||
$lastByte = $instruction['lastByte'];
|
||||
|
||||
$chunkData = substr($videoContent, $firstByte, $lastByte - $firstByte + 1);
|
||||
$chunkLength = $lastByte - $firstByte + 1;
|
||||
$handle = fopen($tempFile, 'r');
|
||||
fseek($handle, $firstByte);
|
||||
$chunkData = fread($handle, $chunkLength);
|
||||
fclose($handle);
|
||||
|
||||
Log::info('LinkedIn Page uploading video chunk', [
|
||||
'index' => $index,
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl
|
|||
$carouselItems = [];
|
||||
|
||||
foreach ($mediaCollection as $media) {
|
||||
if (! str_starts_with($media->mime_type, 'image/')) {
|
||||
if (! $media->isImage()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -288,8 +288,20 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
|
|||
|
||||
private function uploadVideo($mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
$videoContent = file_get_contents($mediaItem->url);
|
||||
$fileSize = strlen($videoContent);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'li_video_');
|
||||
|
||||
try {
|
||||
return $this->doUploadVideo($tempFile, $mediaItem, $ownerUrn);
|
||||
} finally {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
private function doUploadVideo(string $tempFile, $mediaItem, string $ownerUrn): ?string
|
||||
{
|
||||
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
|
||||
|
||||
$fileSize = (int) filesize($tempFile);
|
||||
|
||||
Log::info('LinkedIn initializing video upload', [
|
||||
'owner' => $ownerUrn,
|
||||
|
|
@ -334,7 +346,11 @@ private function uploadVideo($mediaItem, string $ownerUrn): ?string
|
|||
$firstByte = $instruction['firstByte'];
|
||||
$lastByte = $instruction['lastByte'];
|
||||
|
||||
$chunkData = substr($videoContent, $firstByte, $lastByte - $firstByte + 1);
|
||||
$chunkLength = $lastByte - $firstByte + 1;
|
||||
$handle = fopen($tempFile, 'r');
|
||||
fseek($handle, $firstByte);
|
||||
$chunkData = fread($handle, $chunkLength);
|
||||
fclose($handle);
|
||||
|
||||
Log::info('LinkedIn uploading video chunk', [
|
||||
'index' => $index,
|
||||
|
|
|
|||
|
|
@ -71,22 +71,24 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'masto_media_');
|
||||
|
||||
try {
|
||||
$fileContent = file_get_contents($url);
|
||||
if ($fileContent === false) {
|
||||
Log::error('Mastodon failed to read media', ['url' => $url]);
|
||||
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url);
|
||||
|
||||
if (filesize($tempFile) === 0) {
|
||||
Log::error('Mastodon failed to download media', ['url' => $url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine filename from URL if not provided
|
||||
$name = $filename ?? basename(parse_url($url, PHP_URL_PATH));
|
||||
if (empty($name)) {
|
||||
$name = 'media';
|
||||
}
|
||||
|
||||
$response = Http::withToken($account->access_token)
|
||||
->attach('file', $fileContent, $name)
|
||||
->attach('file', fopen($tempFile, 'r'), $name)
|
||||
->post("{$instance}/api/v1/media");
|
||||
|
||||
if ($response->failed()) {
|
||||
|
|
@ -110,6 +112,8 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
|
|||
]);
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -160,9 +160,14 @@ private function publishVideoPin(PostPlatform $postPlatform): array
|
|||
$multipart[] = ['name' => $key, 'contents' => $value];
|
||||
}
|
||||
|
||||
// Get video content
|
||||
$videoContent = file_get_contents($media->url);
|
||||
// Download video to temp file (memory-safe)
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'pin_video_');
|
||||
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url);
|
||||
|
||||
$videoContent = fopen($tempFile, 'r');
|
||||
|
||||
if ($videoContent === false) {
|
||||
@unlink($tempFile);
|
||||
throw new \Exception('Failed to read video file');
|
||||
}
|
||||
|
||||
|
|
@ -173,8 +178,14 @@ private function publishVideoPin(PostPlatform $postPlatform): array
|
|||
];
|
||||
|
||||
$uploadResponse = Http::asMultipart()
|
||||
->timeout(600)
|
||||
->post($uploadUrl, $multipart);
|
||||
|
||||
if (is_resource($videoContent)) {
|
||||
fclose($videoContent);
|
||||
}
|
||||
@unlink($tempFile);
|
||||
|
||||
if ($uploadResponse->failed()) {
|
||||
Log::error('Pinterest video upload failed', [
|
||||
'status' => $uploadResponse->status(),
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
$isVideo = $firstMedia->isVideo();
|
||||
|
||||
// Single media
|
||||
if ($media->count() === 1) {
|
||||
|
|
@ -164,7 +164,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
|
|||
$childContainers = [];
|
||||
|
||||
foreach ($mediaCollection as $media) {
|
||||
$isVideo = str_starts_with($media->mime_type, 'video/');
|
||||
$isVideo = $media->isVideo();
|
||||
|
||||
$params = [
|
||||
'is_carousel_item' => 'true',
|
||||
|
|
@ -315,8 +315,11 @@ private function refreshToken(SocialAccount $account): void
|
|||
|
||||
$data = $response->json();
|
||||
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
||||
$account->update([
|
||||
'access_token' => data_get($data, 'access_token'),
|
||||
'access_token' => $newToken,
|
||||
'refresh_token' => $newToken,
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ public function publish(PostPlatform $postPlatform): array
|
|||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
$isImage = str_starts_with($firstMedia->mime_type, 'image/');
|
||||
$isVideo = $firstMedia->isVideo();
|
||||
$isImage = $firstMedia->isImage();
|
||||
|
||||
if ($isVideo) {
|
||||
return $this->publishVideo($postPlatform, $firstMedia);
|
||||
|
|
@ -76,6 +76,37 @@ private function getHttpClient(): PendingRequest
|
|||
->timeout(120);
|
||||
}
|
||||
|
||||
private function queryCreatorInfo(): array
|
||||
{
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/creator_info/query/");
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('TikTok creator_info query failed', ['body' => $response->body()]);
|
||||
|
||||
return ['privacy_level' => 'SELF_ONLY'];
|
||||
}
|
||||
|
||||
$data = data_get($response->json(), 'data', []);
|
||||
$privacyOptions = data_get($data, 'privacy_level_options', ['SELF_ONLY']);
|
||||
|
||||
// Prefer PUBLIC_TO_EVERYONE > MUTUAL_FOLLOW_FRIENDS > FOLLOWER_OF_CREATOR > SELF_ONLY
|
||||
$preferred = ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'];
|
||||
|
||||
$privacyLevel = 'SELF_ONLY';
|
||||
foreach ($preferred as $level) {
|
||||
if (in_array($level, $privacyOptions)) {
|
||||
$privacyLevel = $level;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'privacy_level' => $privacyLevel,
|
||||
'max_video_post_duration_sec' => data_get($data, 'max_video_post_duration_sec'),
|
||||
];
|
||||
}
|
||||
|
||||
private function publishVideo(PostPlatform $postPlatform, $media): array
|
||||
{
|
||||
Log::info('TikTok publishing video', [
|
||||
|
|
@ -84,11 +115,13 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
|
|||
'content' => $postPlatform->content,
|
||||
]);
|
||||
|
||||
$creatorInfo = $this->queryCreatorInfo();
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/video/init/", [
|
||||
'post_info' => [
|
||||
'title' => $postPlatform->content ?? '',
|
||||
'privacy_level' => 'SELF_ONLY',
|
||||
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
|
||||
'disable_duet' => false,
|
||||
'disable_comment' => false,
|
||||
'disable_stitch' => false,
|
||||
|
|
@ -129,7 +162,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
|
|||
private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): array
|
||||
{
|
||||
$photoUrls = $mediaCollection
|
||||
->filter(fn ($m) => str_starts_with($m->mime_type, 'image/'))
|
||||
->filter(fn ($m) => $m->isImage())
|
||||
->map(fn ($m) => $m->url)
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
@ -144,11 +177,13 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar
|
|||
'content' => $postPlatform->content,
|
||||
]);
|
||||
|
||||
$creatorInfo = $this->queryCreatorInfo();
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/content/init/", [
|
||||
'post_info' => [
|
||||
'title' => $postPlatform->content ?? '',
|
||||
'privacy_level' => 'SELF_ONLY',
|
||||
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
|
||||
'disable_comment' => false,
|
||||
],
|
||||
'source_info' => [
|
||||
|
|
|
|||
|
|
@ -111,28 +111,24 @@ private function getHttpClient(): PendingRequest
|
|||
|
||||
private function uploadMedia($mediaItem): ?array
|
||||
{
|
||||
$mediaContent = file_get_contents($mediaItem->url);
|
||||
$mimeType = $mediaItem->mime_type;
|
||||
$fileSize = strlen($mediaContent);
|
||||
|
||||
// Create temp file
|
||||
// Download to temp file (memory-safe)
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'x_media_');
|
||||
file_put_contents($tempFile, $mediaContent);
|
||||
|
||||
try {
|
||||
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
|
||||
|
||||
$fileSize = filesize($tempFile);
|
||||
$mediaCategory = $this->getMediaCategory($mimeType, $fileSize);
|
||||
|
||||
$isVideo = str_starts_with($mimeType, 'video/');
|
||||
$isGif = $mimeType === 'image/gif';
|
||||
|
||||
// Use chunked upload for:
|
||||
// - Videos (always)
|
||||
// - GIFs (need async processing)
|
||||
// - Files > 5MB (API limit for simple upload)
|
||||
$useChunkedUpload = $isVideo || $isGif || $fileSize > 5 * 1024 * 1024;
|
||||
|
||||
if ($useChunkedUpload) {
|
||||
return $this->chunkedUpload($mediaContent, $mimeType, $mediaCategory);
|
||||
return $this->chunkedUpload($tempFile, $fileSize, $mimeType, $mediaCategory);
|
||||
}
|
||||
|
||||
// Simple upload for small images
|
||||
|
|
@ -162,7 +158,6 @@ private function uploadMedia($mediaItem): ?array
|
|||
|
||||
$responseData = $response->json();
|
||||
|
||||
// v2 API returns data.id
|
||||
$mediaId = $responseData['data']['id'] ?? $responseData['media_id'] ?? null;
|
||||
|
||||
if ($isGif && $mediaId) {
|
||||
|
|
@ -171,23 +166,19 @@ private function uploadMedia($mediaItem): ?array
|
|||
|
||||
return $responseData;
|
||||
} finally {
|
||||
if (file_exists($tempFile)) {
|
||||
unlink($tempFile);
|
||||
}
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
private function chunkedUpload(string $mediaContent, string $mimeType, string $mediaCategory): array
|
||||
private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, string $mediaCategory): array
|
||||
{
|
||||
$totalBytes = strlen($mediaContent);
|
||||
|
||||
Log::info('X chunked upload INIT', [
|
||||
'total_bytes' => $totalBytes,
|
||||
'media_type' => $mimeType,
|
||||
'media_category' => $mediaCategory,
|
||||
]);
|
||||
|
||||
// INIT - Use dedicated initialize endpoint
|
||||
// INIT
|
||||
$initResponse = Http::withToken($this->accessToken)
|
||||
->timeout(60)
|
||||
->post("{$this->baseUrl}/2/media/upload/initialize", [
|
||||
|
|
@ -213,18 +204,24 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m
|
|||
|
||||
Log::info('X chunked upload INIT success', ['media_id' => $mediaId]);
|
||||
|
||||
// APPEND - Upload in 1MB chunks (API limit)
|
||||
$chunkSize = 1 * 1024 * 1024;
|
||||
$chunks = str_split($mediaContent, $chunkSize);
|
||||
// APPEND - Read from temp file in 5MB chunks (memory-safe)
|
||||
$chunkSize = 5 * 1024 * 1024;
|
||||
$handle = fopen($tempFile, 'r');
|
||||
$index = 0;
|
||||
|
||||
while (! feof($handle)) {
|
||||
$chunk = fread($handle, $chunkSize);
|
||||
|
||||
if ($chunk === '' || $chunk === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
Log::info('X chunked upload APPEND', [
|
||||
'media_id' => $mediaId,
|
||||
'segment' => $index,
|
||||
'chunk_size' => strlen($chunk),
|
||||
]);
|
||||
|
||||
// APPEND uses the new v2 endpoint with media_id in URL
|
||||
$appendResponse = Http::withToken($this->accessToken)
|
||||
->timeout(300)
|
||||
->attach('media', $chunk, 'chunk'.$index)
|
||||
|
|
@ -240,8 +237,12 @@ private function chunkedUpload(string $mediaContent, string $mimeType, string $m
|
|||
]);
|
||||
$this->handleApiError($appendResponse, 'Failed to append chunk');
|
||||
}
|
||||
|
||||
$index++;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
// FINALIZE - Use the new v2 endpoint
|
||||
Log::info('X chunked upload FINALIZE', ['media_id' => $mediaId]);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,33 +7,18 @@
|
|||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Google\Client as GoogleClient;
|
||||
use Google\Service\YouTube;
|
||||
use Google\Service\YouTube\Video;
|
||||
use Google\Service\YouTube\VideoSnippet;
|
||||
use Google\Service\YouTube\VideoStatus;
|
||||
use Google_Http_MediaFileUpload;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class YouTubePublisher
|
||||
{
|
||||
/**
|
||||
* Google/YouTube API error codes that indicate token issues.
|
||||
*
|
||||
* @see https://developers.google.com/youtube/v3/docs/errors
|
||||
*/
|
||||
private const TOKEN_ERROR_CODES = [
|
||||
'invalid_grant',
|
||||
'invalid_token',
|
||||
'unauthorized',
|
||||
];
|
||||
|
||||
private const TOKEN_ERROR_REASONS = [
|
||||
'authError',
|
||||
'forbidden',
|
||||
'unauthorized',
|
||||
];
|
||||
|
||||
private string $baseUrl = 'https://www.googleapis.com';
|
||||
|
||||
private string $accessToken;
|
||||
private const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
|
|
@ -44,8 +29,6 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$account->refresh();
|
||||
}
|
||||
|
||||
$this->accessToken = $account->access_token;
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
if ($media->isEmpty()) {
|
||||
|
|
@ -53,22 +36,38 @@ public function publish(PostPlatform $postPlatform): array
|
|||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
$isVideo = str_starts_with($firstMedia->mime_type, 'video/');
|
||||
|
||||
if (! $isVideo) {
|
||||
if (! $firstMedia->isVideo()) {
|
||||
throw new \Exception('YouTube Shorts only supports video content.');
|
||||
}
|
||||
|
||||
return $this->publishShort($postPlatform, $firstMedia);
|
||||
return $this->publishShort($postPlatform, $firstMedia, $account);
|
||||
}
|
||||
|
||||
private function getHttpClient(): PendingRequest
|
||||
private function createGoogleClient(SocialAccount $account): GoogleClient
|
||||
{
|
||||
return Http::withToken($this->accessToken)
|
||||
->timeout(600);
|
||||
$client = new GoogleClient;
|
||||
$client->setClientId(config('services.google.client_id'));
|
||||
$client->setClientSecret(config('services.google.client_secret'));
|
||||
|
||||
$tokenData = [
|
||||
'access_token' => $account->access_token,
|
||||
'created' => $account->token_expires_at
|
||||
? $account->token_expires_at->subHour()->getTimestamp()
|
||||
: time(),
|
||||
'expires_in' => 3600,
|
||||
];
|
||||
|
||||
if ($account->refresh_token) {
|
||||
$tokenData['refresh_token'] = $account->refresh_token;
|
||||
}
|
||||
|
||||
$client->setAccessToken($tokenData);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function publishShort(PostPlatform $postPlatform, $media): array
|
||||
private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account): array
|
||||
{
|
||||
if (empty($postPlatform->content)) {
|
||||
throw new \Exception('YouTube Shorts require a title. Please add text to your post.');
|
||||
|
|
@ -82,90 +81,114 @@ private function publishShort(PostPlatform $postPlatform, $media): array
|
|||
'title' => $title,
|
||||
]);
|
||||
|
||||
// Step 1: Get video content
|
||||
$videoContent = file_get_contents($media->url);
|
||||
$fileSize = strlen($videoContent);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_');
|
||||
$handle = null;
|
||||
|
||||
Log::info('YouTube video file size', ['size' => $fileSize]);
|
||||
try {
|
||||
// Download video to temp file (memory-safe)
|
||||
$downloadResponse = Http::withOptions(['sink' => $tempFile])
|
||||
->timeout(600)
|
||||
->get($media->url);
|
||||
|
||||
// Step 2: Initialize resumable upload
|
||||
$initResponse = $this->getHttpClient()
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json; charset=UTF-8',
|
||||
'X-Upload-Content-Length' => $fileSize,
|
||||
'X-Upload-Content-Type' => $media->mime_type,
|
||||
])
|
||||
->post("{$this->baseUrl}/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", [
|
||||
'snippet' => [
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'categoryId' => '22', // People & Blogs
|
||||
],
|
||||
'status' => [
|
||||
'privacyStatus' => 'public',
|
||||
'selfDeclaredMadeForKids' => false,
|
||||
],
|
||||
if ($downloadResponse->failed()) {
|
||||
throw new \Exception('Failed to download video for YouTube upload: HTTP '.$downloadResponse->status());
|
||||
}
|
||||
|
||||
$fileSize = filesize($tempFile);
|
||||
|
||||
if ($fileSize === false || $fileSize < 1024) {
|
||||
throw new \Exception('Downloaded video is too small or empty ('.$fileSize.' bytes), aborting upload');
|
||||
}
|
||||
|
||||
Log::info('YouTube video downloaded', ['size' => $fileSize]);
|
||||
|
||||
// Set up Google Client with deferred mode for resumable upload
|
||||
$client = $this->createGoogleClient($account);
|
||||
$client->setDefer(true);
|
||||
|
||||
$youtube = new YouTube($client);
|
||||
|
||||
// Build video metadata
|
||||
$snippet = new VideoSnippet;
|
||||
$snippet->setTitle($title);
|
||||
$snippet->setDescription($description);
|
||||
$snippet->setCategoryId('22');
|
||||
|
||||
$status = new VideoStatus;
|
||||
$status->setPrivacyStatus('public');
|
||||
$status->setSelfDeclaredMadeForKids(false);
|
||||
|
||||
$video = new Video;
|
||||
$video->setSnippet($snippet);
|
||||
$video->setStatus($status);
|
||||
|
||||
// Initialize resumable upload request
|
||||
$insertRequest = $youtube->videos->insert('snippet,status', $video);
|
||||
|
||||
$mediaUpload = new Google_Http_MediaFileUpload(
|
||||
$client,
|
||||
$insertRequest,
|
||||
$media->mime_type ?: 'video/mp4',
|
||||
null,
|
||||
true,
|
||||
self::CHUNK_SIZE
|
||||
);
|
||||
$mediaUpload->setFileSize($fileSize);
|
||||
|
||||
// Upload in chunks (memory-safe for large files)
|
||||
$uploadStatus = false;
|
||||
$handle = fopen($tempFile, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new \Exception('Failed to open temp file for YouTube upload');
|
||||
}
|
||||
|
||||
while (! $uploadStatus && ! feof($handle)) {
|
||||
$chunk = fread($handle, self::CHUNK_SIZE);
|
||||
$uploadStatus = $mediaUpload->nextChunk($chunk);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
$handle = null;
|
||||
|
||||
$client->setDefer(false);
|
||||
|
||||
if (! $uploadStatus instanceof Video) {
|
||||
throw new \Exception('YouTube upload failed: no video object returned');
|
||||
}
|
||||
|
||||
$videoId = $uploadStatus->getId();
|
||||
|
||||
Log::info('YouTube upload success', ['video_id' => $videoId]);
|
||||
|
||||
return [
|
||||
'id' => $videoId,
|
||||
'url' => "https://www.youtube.com/shorts/{$videoId}",
|
||||
];
|
||||
} catch (\Google\Service\Exception $e) {
|
||||
$this->handleGoogleError($e);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('YouTube upload failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
if ($initResponse->failed()) {
|
||||
Log::error('YouTube upload init failed', [
|
||||
'status' => $initResponse->status(),
|
||||
'body' => $initResponse->body(),
|
||||
]);
|
||||
$this->handleApiError($initResponse, 'YouTube API error');
|
||||
throw $e;
|
||||
} finally {
|
||||
if ($handle !== null && is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
@unlink($tempFile);
|
||||
}
|
||||
|
||||
$uploadUrl = $initResponse->header('Location');
|
||||
|
||||
if (! $uploadUrl) {
|
||||
throw new \Exception('YouTube did not return an upload URL');
|
||||
}
|
||||
|
||||
Log::info('YouTube upload initialized', ['uploadUrl' => $uploadUrl]);
|
||||
|
||||
// Step 3: Upload the video content
|
||||
$uploadResponse = Http::withToken($this->accessToken)
|
||||
->withHeaders([
|
||||
'Content-Type' => $media->mime_type,
|
||||
'Content-Length' => $fileSize,
|
||||
])
|
||||
->timeout(600)
|
||||
->withBody($videoContent, $media->mime_type)
|
||||
->put($uploadUrl);
|
||||
|
||||
if ($uploadResponse->failed()) {
|
||||
Log::error('YouTube video upload failed', [
|
||||
'status' => $uploadResponse->status(),
|
||||
'body' => $uploadResponse->body(),
|
||||
]);
|
||||
$this->handleApiError($uploadResponse, 'YouTube upload error');
|
||||
}
|
||||
|
||||
$data = $uploadResponse->json();
|
||||
|
||||
Log::info('YouTube upload response', ['data' => $data]);
|
||||
|
||||
$videoId = data_get($data, 'id', null);
|
||||
|
||||
if (! $videoId) {
|
||||
throw new \Exception('YouTube did not return a video ID');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $videoId,
|
||||
'url' => "https://www.youtube.com/shorts/{$videoId}",
|
||||
];
|
||||
}
|
||||
|
||||
private function buildTitle(string $content): string
|
||||
{
|
||||
// YouTube title max is 100 characters
|
||||
// For Shorts, add #Shorts hashtag to help YouTube classify it
|
||||
$maxLength = 100;
|
||||
$shortsTag = ' #Shorts';
|
||||
$availableLength = $maxLength - strlen($shortsTag);
|
||||
|
||||
// Get first line or first sentence as title
|
||||
$title = strtok($content, "\n");
|
||||
$title = strtok($title, '.');
|
||||
|
||||
|
|
@ -191,7 +214,8 @@ private function refreshToken(SocialAccount $account): void
|
|||
|
||||
if ($response->failed()) {
|
||||
Log::error('YouTube token refresh failed', ['body' => $response->body()]);
|
||||
$this->handleApiError($response, 'Failed to refresh YouTube token');
|
||||
|
||||
throw new TokenExpiredException('Failed to refresh YouTube token: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
|
@ -205,36 +229,24 @@ private function refreshToken(SocialAccount $account): void
|
|||
Log::info('YouTube token refreshed successfully');
|
||||
}
|
||||
|
||||
private function handleApiError(Response $response, string $context): void
|
||||
private function handleGoogleError(\Google\Service\Exception $e): never
|
||||
{
|
||||
$body = $response->json() ?? [];
|
||||
$message = $e->getMessage();
|
||||
$errors = $e->getErrors();
|
||||
$reason = data_get($errors, '0.reason');
|
||||
|
||||
// Google OAuth error format
|
||||
$errorCode = $body['error'] ?? null;
|
||||
$errorDescription = $body['error_description'] ?? null;
|
||||
Log::error('YouTube API error', [
|
||||
'message' => $message,
|
||||
'reason' => $reason,
|
||||
'errors' => $errors,
|
||||
]);
|
||||
|
||||
// YouTube API error format
|
||||
$error = $body['error'] ?? [];
|
||||
if (is_array($error)) {
|
||||
$errors = $error['errors'] ?? [];
|
||||
$reason = $errors[0]['reason'] ?? null;
|
||||
$message = $error['message'] ?? $errorDescription ?? $response->body();
|
||||
} else {
|
||||
$reason = null;
|
||||
$message = $errorDescription ?? $response->body();
|
||||
$tokenReasons = ['authError', 'forbidden', 'unauthorized', 'invalid_grant'];
|
||||
|
||||
if ($e->getCode() === 401 || in_array($reason, $tokenReasons)) {
|
||||
throw new TokenExpiredException("YouTube API error: {$message}", $reason);
|
||||
}
|
||||
|
||||
$isTokenError = $response->status() === 401
|
||||
|| in_array($errorCode, self::TOKEN_ERROR_CODES)
|
||||
|| in_array($reason, self::TOKEN_ERROR_REASONS);
|
||||
|
||||
if ($isTokenError) {
|
||||
throw new TokenExpiredException(
|
||||
"{$context}: {$message}",
|
||||
is_string($errorCode) ? $errorCode : $reason
|
||||
);
|
||||
}
|
||||
|
||||
throw new \Exception("{$context}: {$message}");
|
||||
throw new \Exception("YouTube API error: {$message}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"google/apiclient": "^2.19",
|
||||
"inertiajs/inertia-laravel": "^3.0",
|
||||
"laravel/ai": "^0.4.2",
|
||||
"laravel/boost": "^2.0",
|
||||
|
|
|
|||
229
composer.lock
generated
229
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "f97b0298cdb7a80d1c5d1706165b6fe9",
|
||||
"content-hash": "2f1a57707f17ee68dc1090c641f8d753",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -971,6 +971,184 @@
|
|||
],
|
||||
"time": "2025-12-03T09:33:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "google/apiclient",
|
||||
"version": "v2.19.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/googleapis/google-api-php-client.git",
|
||||
"reference": "703ba9acfaf4ba71306108207feafb6d1d137eb0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/googleapis/google-api-php-client/zipball/703ba9acfaf4ba71306108207feafb6d1d137eb0",
|
||||
"reference": "703ba9acfaf4ba71306108207feafb6d1d137eb0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"firebase/php-jwt": "^6.0||^7.0",
|
||||
"google/apiclient-services": "~0.350",
|
||||
"google/auth": "^1.37",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/psr7": "^2.6",
|
||||
"monolog/monolog": "^2.9||^3.0",
|
||||
"php": "^8.1",
|
||||
"phpseclib/phpseclib": "^3.0.50"
|
||||
},
|
||||
"require-dev": {
|
||||
"cache/filesystem-adapter": "^1.1",
|
||||
"composer/composer": "^2.9",
|
||||
"phpcompatibility/php-compatibility": "^9.2",
|
||||
"phpspec/prophecy-phpunit": "^2.1",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"squizlabs/php_codesniffer": "^3.8",
|
||||
"symfony/css-selector": "~2.1",
|
||||
"symfony/dom-crawler": "~2.1"
|
||||
},
|
||||
"suggest": {
|
||||
"cache/filesystem-adapter": "For caching certs and tokens (using Google\\Client::setCache)"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"component": {
|
||||
"entry": "src/Client.php"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/aliases.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Google\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"src/aliases.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"description": "Client library for Google APIs",
|
||||
"homepage": "http://developers.google.com/api-client-library/php",
|
||||
"keywords": [
|
||||
"google"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/googleapis/google-api-php-client/issues",
|
||||
"source": "https://github.com/googleapis/google-api-php-client/tree/v2.19.2"
|
||||
},
|
||||
"time": "2026-03-30T18:54:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "google/apiclient-services",
|
||||
"version": "v0.435.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/googleapis/google-api-php-client-services.git",
|
||||
"reference": "1edf0f5f2876945c372366107b4d7a387b17a6b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/1edf0f5f2876945c372366107b4d7a387b17a6b9",
|
||||
"reference": "1edf0f5f2876945c372366107b4d7a387b17a6b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"autoload.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Google\\Service\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"description": "Client library for Google APIs",
|
||||
"homepage": "http://developers.google.com/api-client-library/php",
|
||||
"keywords": [
|
||||
"google"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/googleapis/google-api-php-client-services/issues",
|
||||
"source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.435.0"
|
||||
},
|
||||
"time": "2026-03-01T01:14:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "google/auth",
|
||||
"version": "v1.50.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/googleapis/google-auth-library-php.git",
|
||||
"reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/870c17ee3a1d73338d39a9ffa77a700ba77f5a83",
|
||||
"reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"firebase/php-jwt": "^6.0||^7.0",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/psr7": "^2.4.5",
|
||||
"php": "^8.1",
|
||||
"psr/cache": "^2.0||^3.0",
|
||||
"psr/http-message": "^1.1||^2.0",
|
||||
"psr/log": "^2.0||^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"guzzlehttp/promises": "^2.0",
|
||||
"kelvinmo/simplejwt": "^1.1.0",
|
||||
"phpseclib/phpseclib": "^3.0.35",
|
||||
"phpspec/prophecy-phpunit": "^2.1",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"sebastian/comparator": ">=1.2.3",
|
||||
"squizlabs/php_codesniffer": "^4.0",
|
||||
"symfony/filesystem": "^6.3||^7.3",
|
||||
"symfony/process": "^6.0||^7.0",
|
||||
"webmozart/assert": "^1.11||^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Google\\Auth\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"description": "Google Auth Library for PHP",
|
||||
"homepage": "https://github.com/google/google-auth-library-php",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"google",
|
||||
"oauth2"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://cloud.google.com/php/docs/reference/auth/latest",
|
||||
"issues": "https://github.com/googleapis/google-auth-library-php/issues",
|
||||
"source": "https://github.com/googleapis/google-auth-library-php/tree/v1.50.1"
|
||||
},
|
||||
"time": "2026-03-18T20:03:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "graham-campbell/result-type",
|
||||
"version": "v1.1.4",
|
||||
|
|
@ -4729,6 +4907,55 @@
|
|||
],
|
||||
"time": "2026-03-12T17:55:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/cache.git",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Cache\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for caching libraries",
|
||||
"keywords": [
|
||||
"cache",
|
||||
"psr",
|
||||
"psr-6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/cache/tree/3.0.0"
|
||||
},
|
||||
"time": "2021-02-03T23:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@
|
|||
'maxJobs' => 0,
|
||||
'memory' => 128,
|
||||
'tries' => 1,
|
||||
'timeout' => 60,
|
||||
'timeout' => 630,
|
||||
'nice' => 0,
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 660),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ const getDefaultContentType = (platform: string): string => {
|
|||
'threads': 'threads_post',
|
||||
'pinterest': 'pinterest_pin',
|
||||
'bluesky': 'bluesky_post',
|
||||
'mastodon': 'mastodon_post',
|
||||
};
|
||||
return defaults[platform] || '';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\ApiToken;
|
||||
|
|
@ -125,10 +126,13 @@
|
|||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
'content' => 'Updated content',
|
||||
'content_type' => ContentType::LinkedInPost->value,
|
||||
],
|
||||
],
|
||||
])
|
||||
|
|
@ -137,13 +141,32 @@
|
|||
|
||||
it('cannot update post from another workspace', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$otherSocialAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
]);
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$postPlatform = PostPlatform::factory()->linkedin()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $otherSocialAccount->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->putJson(route('api.posts.update', $post), [])
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
'content' => 'Test',
|
||||
'content_type' => ContentType::LinkedInPost->value,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
|
|
@ -154,8 +177,24 @@
|
|||
'status' => PostStatus::Published,
|
||||
]);
|
||||
|
||||
$postPlatform = PostPlatform::factory()->linkedin()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->putJson(route('api.posts.update', $post), [])
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
'content' => 'Test',
|
||||
'content_type' => ContentType::LinkedInPost->value,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -258,3 +258,24 @@
|
|||
expect($this->post->status)->toBe(PostStatus::Failed);
|
||||
Queue::assertPushed(SendNotification::class);
|
||||
});
|
||||
|
||||
test('publish to social platform skips if already published (idempotency)', function () {
|
||||
Event::fake();
|
||||
|
||||
// Mark as already published
|
||||
$this->postPlatform->update([
|
||||
'status' => PlatformStatus::Published,
|
||||
'platform_post_id' => 'existing-123',
|
||||
]);
|
||||
|
||||
$publisher = Mockery::mock(LinkedInPublisher::class);
|
||||
$publisher->shouldNotReceive('publish');
|
||||
|
||||
$this->app->instance(LinkedInPublisher::class, $publisher);
|
||||
|
||||
(new PublishToSocialPlatform($this->postPlatform))->handle();
|
||||
|
||||
// Should not have called publish
|
||||
$this->postPlatform->refresh();
|
||||
expect($this->postPlatform->platform_post_id)->toBe('existing-123');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
|
|
@ -219,6 +220,7 @@
|
|||
|
||||
$postPlatform->refresh();
|
||||
expect($postPlatform->content)->toBe('Updated content');
|
||||
expect($postPlatform->content_type)->toBe(ContentType::LinkedInPost);
|
||||
});
|
||||
|
||||
test('update post cannot update published posts', function () {
|
||||
|
|
@ -228,8 +230,21 @@
|
|||
'status' => PostStatus::Published,
|
||||
]);
|
||||
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
'content' => 'Test content',
|
||||
'content_type' => ContentType::LinkedInPost->value,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
|
@ -254,6 +269,7 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
||||
'status' => 'publishing',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
|
|
@ -381,6 +397,7 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
|
|
@ -418,6 +435,7 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
|
|
@ -456,6 +474,7 @@
|
|||
// Update with different labels
|
||||
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
|
|
@ -487,6 +506,7 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
||||
'status' => 'draft',
|
||||
'synced' => true,
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $postPlatform->id,
|
||||
|
|
|
|||
|
|
@ -236,9 +236,14 @@
|
|||
Http::assertSent(fn ($request) => str_contains($request->url(), 'graph.facebook.com'));
|
||||
});
|
||||
|
||||
test('does not refresh instagram token as it uses long-lived tokens', function () {
|
||||
test('refreshes instagram token when expired', function () {
|
||||
Http::fake([
|
||||
'graph.instagram.com/*' => Http::response(['id' => '123', 'username' => 'test'], 200),
|
||||
'graph.instagram.com/refresh_access_token*' => Http::response([
|
||||
'access_token' => 'new-instagram-token',
|
||||
'token_type' => 'bearer',
|
||||
'expires_in' => 5184000,
|
||||
], 200),
|
||||
'graph.instagram.com/v24.0/me*' => Http::response(['id' => '123', 'username' => 'test'], 200),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->instagram()->create([
|
||||
|
|
@ -250,8 +255,12 @@
|
|||
|
||||
expect($result)->toBeTrue();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'graph.instagram.com'));
|
||||
$account->refresh();
|
||||
expect($account->access_token)->toBe('new-instagram-token');
|
||||
expect($account->refresh_token)->toBe('new-instagram-token');
|
||||
|
||||
Http::assertSentCount(2);
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
|
||||
});
|
||||
|
||||
test('refreshes token when expiring soon', function () {
|
||||
|
|
|
|||
|
|
@ -222,3 +222,36 @@
|
|||
expect($user->has_photo)->toBeTrue();
|
||||
expect($user->photo_url)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('isVideo detects mp4 files', function () {
|
||||
$media = new Media(['path' => 'medias/test.mp4']);
|
||||
expect($media->isVideo())->toBeTrue();
|
||||
expect($media->isImage())->toBeFalse();
|
||||
});
|
||||
|
||||
test('isVideo detects mov files', function () {
|
||||
$media = new Media(['path' => 'medias/test.mov']);
|
||||
expect($media->isVideo())->toBeTrue();
|
||||
});
|
||||
|
||||
test('isImage detects jpg files', function () {
|
||||
$media = new Media(['path' => 'medias/test.jpg']);
|
||||
expect($media->isImage())->toBeTrue();
|
||||
expect($media->isVideo())->toBeFalse();
|
||||
});
|
||||
|
||||
test('isImage detects png files', function () {
|
||||
$media = new Media(['path' => 'medias/test.png']);
|
||||
expect($media->isImage())->toBeTrue();
|
||||
});
|
||||
|
||||
test('isVideo returns false for image files', function () {
|
||||
$media = new Media(['path' => 'medias/test.webp']);
|
||||
expect($media->isVideo())->toBeFalse();
|
||||
expect($media->isImage())->toBeTrue();
|
||||
});
|
||||
|
||||
test('mp4 with quicktime mime still detected as video by path', function () {
|
||||
$media = new Media(['path' => 'medias/test.mp4', 'mime_type' => 'video/quicktime']);
|
||||
expect($media->isVideo())->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue