where('provider', 'youtube')->where('is_active', true)->first(); if (! $credential) { $engine->failStep($step, 'No active YouTube credential configured.'); return; } $videoAsset = $step->videoProject->assets()->where('type', 'video')->orderByDesc('version')->first(); if (! $videoAsset) { $engine->failStep($step, 'No finished video available — complete a video_editing step first.'); return; } try { $accessToken = $this->refreshAccessToken($credential); } catch (RuntimeException|ConnectionException $e) { $engine->failStep($step, "could not refresh YouTube access token: {$e->getMessage()}"); return; } $metadata = $this->resolveMetadata($step); $input = $step->input ?? []; $publishAt = $input['publish_at'] ?? null; $snippet = [ 'title' => $metadata['title'] ?? $step->videoProject->title, 'description' => $metadata['description'] ?? ($step->videoProject->description ?? ''), 'tags' => $metadata['tags'] ?? [], ]; $status = array_filter([ 'privacyStatus' => $publishAt ? 'private' : ($input['privacy_status'] ?? 'private'), 'publishAt' => $publishAt, ]); $videoBytes = $this->assets->contents($videoAsset); try { $initResponse = Http::withToken($accessToken) ->withHeaders([ 'X-Upload-Content-Type' => 'video/mp4', 'X-Upload-Content-Length' => (string) strlen($videoBytes), ]) ->post('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', [ 'snippet' => $snippet, 'status' => $status, ]); } catch (ConnectionException $e) { $engine->failStep($step, "could not connect to YouTube: {$e->getMessage()}"); return; } if ($initResponse->failed()) { $engine->failStep($step, $initResponse->json('error.message') ?? 'unexpected response from YouTube while starting the upload'); return; } $uploadUrl = $initResponse->header('Location'); if (! $uploadUrl) { $engine->failStep($step, 'unexpected response from YouTube: missing upload session location'); return; } try { $uploadResponse = Http::withToken($accessToken) ->withBody($videoBytes, 'video/mp4') ->put($uploadUrl); } catch (ConnectionException $e) { $engine->failStep($step, "could not upload video to YouTube: {$e->getMessage()}"); return; } if ($uploadResponse->failed()) { $engine->failStep($step, $uploadResponse->json('error.message') ?? 'unexpected response from YouTube while uploading the video'); return; } $videoId = $uploadResponse->json('id'); if (! $videoId) { $engine->failStep($step, 'unexpected response from YouTube: missing video id'); return; } $engine->completeStep($step, [ 'youtube_video_id' => $videoId, 'youtube_url' => "https://www.youtube.com/watch?v={$videoId}", ]); } /** * @throws RuntimeException|ConnectionException */ private function refreshAccessToken(AiProvider $credential): string { $config = $credential->config ?? []; $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'client_id' => $config['client_id'] ?? null, 'client_secret' => $config['client_secret'] ?? null, 'refresh_token' => $credential->api_key, 'grant_type' => 'refresh_token', ]); if ($response->failed()) { throw new RuntimeException($response->json('error_description') ?? 'token refresh failed'); } $token = $response->json('access_token'); if (! $token) { throw new RuntimeException('no access_token in refresh response'); } return $token; } /** * @return array */ private function resolveMetadata(WorkflowStep $step): array { $input = $step->input ?? []; if (! empty($input['title']) || ! empty($input['description'])) { return $input; } $metaAsset = $step->videoProject->assets() ->where('type', 'other') ->whereJsonContains('metadata->kind', 'metadata') ->orderByDesc('version') ->first(); if (! $metaAsset) { return []; } return json_decode($this->assets->contents($metaAsset), true) ?? []; } }