Building a Podcast Automation Platform with Ittybit

View Markdown

Part 2 - Your First Media Processing Tasks

In the last article, we laid the groundwork: an API that accepts episode uploads and stores them in Ittybit. We ended with a file sitting in Ittybit's storage and a ProcessEpisodeJob being dispatched to our queue. That's where things get interesting.

See, here's the thing about media processing: it's slow. Not "your API took 500ms instead of 200ms" slow. I mean "go make a coffee, check Twitter, maybe walk your dog" slow. A 60-minute 1080p video can take 5-15 minutes to process, depending on what you're doing with it. Extract audio, transcode video, generate transcripts, detect chapters - each of these is compute-intensive work.

This is why we need queues. This is why we need webhooks. And this is why understanding async workflows isn't just a nice-to-have - it's fundamental to building media processing applications that don't collapse under their own weight.

Let's dig in.

The Reality of Media Processing

Before we write any code, I want you to understand what we're actually dealing with here. When you tell Ittybit "extract audio from this video," here's what happens behind the scenes:

  1. Ittybit queues your task
  2. A worker picks it up and downloads your video
  3. FFmpeg (or similar) decodes the video, extracts audio streams
  4. The audio gets transcoded to your desired format (MP3, AAC, etc.)
  5. The output file gets uploaded to Ittybit's CDN
  6. Ittybit sends you a webhook: "Hey, it's done"

For a 2.5GB, 60-minute video, this might take 8-12 minutes. If you're doing this synchronously - blocking your API response while waiting - you've got a timeout waiting to happen. Your HTTP client gives up. Your reverse proxy gives up. Everything gives up.

The solution? Embrace the async. Fire off the work, return immediately, and get notified when it's done.

The Processing Pipeline Architecture

Here's how our system will work:

1. User confirms upload
   └─> Dispatch ProcessEpisodeJob

2. ProcessEpisodeJob (our queue worker)
   └─> Create Media object in Ittybit
   └─> Create Audio task (extract MP3)
   └─> Create Video task (transcode for YouTube)
   └─> Create Transcript task (speech-to-text)
   └─> Save task IDs to database
   └─> Update episode status: "processing"

3. Ittybit processes (5-15 minutes)
   └─> Audio extraction completes → webhook
   └─> Video transcode completes → webhook
   └─> Transcript generation completes → webhook

4. Our webhook handler receives each completion
   └─> Update task status in database
   └─> Check if ALL tasks complete
   └─> If yes: Dispatch upload jobs (Transistor, YouTube)

Let's build this step by step.

Expanding the Database Schema

First, we need a way to track individual processing tasks. Remember: we're firing off multiple tasks (audio, video, transcript) and they complete at different times. We need to know what's finished and what's still cooking.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('processing_tasks', function (Blueprint $table) {
            $table->id();
            $table->foreignUuid('episode_id')->constrained()->onDelete('cascade');

            // Task identification
            $table->string('ittybit_task_id')->unique();
            $table->enum('type', [
                'audio_extraction',
                'video_transcode',
                'transcript_generation',
                'chapter_detection',
            ]);

            // Task status
            $table->enum('status', [
                'pending',
                'processing',
                'completed',
                'failed',
            ])->default('pending');

            // Results
            $table->string('output_file_id')->nullable();
            $table->string('output_url')->nullable();
            $table->text('error_message')->nullable();
            $table->json('metadata')->nullable();

            $table->timestamps();

            $table->index(['episode_id', 'type']);
            $table->index('status');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('processing_tasks');
    }
};

I also want to add a few columns to our episodes table:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('episodes', function (Blueprint $table) {
            // Processed outputs
            $table->string('audio_file_id')->nullable()->after('ittybit_media_id');
            $table->string('audio_url')->nullable()->after('audio_file_id');
            $table->string('video_file_id')->nullable()->after('audio_url');
            $table->string('video_url')->nullable()->after('video_file_id');
            $table->longText('transcript')->nullable()->after('video_url');

            // Processing timestamps
            $table->timestamp('processing_started_at')->nullable()->after('status');
            $table->timestamp('processing_completed_at')->nullable()->after('processing_started_at');
        });
    }

    public function down(): void
    {
        Schema::table('episodes', function (Blueprint $table) {
            $table->dropColumn([
                'audio_file_id',
                'audio_url',
                'video_file_id',
                'video_url',
                'transcript',
                'processing_started_at',
                'processing_completed_at',
            ]);
        });
    }
};

Now we have a place to track everything.

Expanding the Ittybit Service

Before we build the job, let's add methods to our IttybitService for creating tasks:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class IttybitService
{
    private string $apiKey;
    private string $baseUrl = 'https://api.ittybit.com';

    public function __construct()
    {
        $this->apiKey = config('services.ittybit.api_key');

        if (empty($this->apiKey)) {
            throw new \Exception('Ittybit API key not configured');
        }
    }

    // ... previous methods (generateUploadSignature, etc.) ...

    /**
     * Create an audio extraction task
     */
    public function createAudioTask(
        string $fileId,
        array $options = []
    ): array {
        $taskData = [
            'file_id' => $fileId,
            'kind' => 'audio',
            'format' => $options['format'] ?? 'mp3',
            'bitrate' => $options['bitrate'] ?? 192000, // 192kbps
            'channels' => $options['channels'] ?? 2, // stereo
            'ref' => $options['ref'] ?? 'podcast_audio',
        ];

        // Add webhook if configured
        if ($webhookUrl = config('services.ittybit.webhook_url')) {
            $taskData['webhook_url'] = $webhookUrl;
        }

        return $this->createTask($taskData);
    }

    /**
     * Create a video transcoding task
     */
    public function createVideoTask(
        string $fileId,
        array $options = []
    ): array {
        $taskData = [
            'file_id' => $fileId,
            'kind' => 'video',
            'format' => $options['format'] ?? 'mp4',
            'codec' => $options['codec'] ?? 'h264',
            'width' => $options['width'] ?? 1920,
            'height' => $options['height'] ?? 1080,
            'fps' => $options['fps'] ?? 30,
            'bitrate' => $options['bitrate'] ?? 5000000, // 5Mbps for 1080p
            'ref' => $options['ref'] ?? 'youtube_video',
        ];

        if ($webhookUrl = config('services.ittybit.webhook_url')) {
            $taskData['webhook_url'] = $webhookUrl;
        }

        return $this->createTask($taskData);
    }

    /**
     * Create a transcript generation task
     */
    public function createTranscriptTask(
        string $fileId,
        array $options = []
    ): array {
        $taskData = [
            'file_id' => $fileId,
            'kind' => 'speech',
            'language' => $options['language'] ?? 'en',
            'ref' => $options['ref'] ?? 'transcript',
        ];

        if ($webhookUrl = config('services.ittybit.webhook_url')) {
            $taskData['webhook_url'] = $webhookUrl;
        }

        return $this->createTask($taskData);
    }

    /**
     * Generic task creation method
     */
    private function createTask(array $taskData): array
    {
        $response = Http::withHeaders([
            'Authorization' => "Bearer {$this->apiKey}",
            'Accept-Version' => '2025-08-20',
        ])->post("{$this->baseUrl}/tasks", $taskData);

        if ($response->failed()) {
            Log::error('Failed to create Ittybit task', [
                'status' => $response->status(),
                'body' => $response->body(),
                'task_data' => $taskData,
            ]);

            throw new \Exception('Failed to create processing task');
        }

        return $response->json();
    }

    /**
     * Get task details
     */
    public function getTask(string $taskId): array
    {
        $response = Http::withHeaders([
            'Authorization' => "Bearer {$this->apiKey}",
            'Accept-Version' => '2025-08-20',
        ])->get("{$this->baseUrl}/tasks/{$taskId}");

        if ($response->failed()) {
            throw new \Exception("Failed to fetch task: {$taskId}");
        }

        return $response->json();
    }

    /**
     * Get media details with all files
     */
    public function getMedia(string $mediaId): array
    {
        $response = Http::withHeaders([
            'Authorization' => "Bearer {$this->apiKey}",
            'Accept-Version' => '2025-08-20',
        ])->get("{$this->baseUrl}/media/{$mediaId}");

        if ($response->failed()) {
            throw new \Exception("Failed to fetch media: {$mediaId}");
        }

        return $response->json();
    }
}

Notice how each task type has its own method with sensible defaults? This is intentional. I could have made one giant createTask() method that takes a million parameters, but that's a recipe for confusion. Better to have focused, purpose-built methods.

Also notice the ref parameter. This is Ittybit's way of letting you tag outputs. When you create an audio task with ref: 'podcast_audio', you can later find that file by its ref. It's like giving your files nicknames.

The ProcessEpisodeJob: Where the Magic Happens

Alright, time for the main event. This job is the conductor of our orchestra. It creates all the tasks, saves their IDs, and sets everything in motion.

<?php

namespace App\Jobs;

use App\Models\Episode;
use App\Models\ProcessingTask;
use App\Services\IttybitService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ProcessEpisodeJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $timeout = 120;

    public function __construct(
        public Episode $episode
    ) {}

    public function handle(IttybitService $ittybit): void
    {
        Log::info("Starting episode processing", [
            'episode_id' => $this->episode->id,
        ]);

        try {
            DB::beginTransaction();

            // Step 1: Create media object in Ittybit if needed
            if (!$this->episode->ittybit_media_id) {
                $media = $ittybit->createMediaFromFile(
                    fileId: $this->episode->ittybit_file_id,
                    metadata: [
                        'title' => $this->episode->title,
                        'description' => $this->episode->description,
                        'episode_id' => $this->episode->id,
                    ]
                );

                $this->episode->update([
                    'ittybit_media_id' => $media['id'],
                ]);

                Log::info("Created Ittybit media", [
                    'episode_id' => $this->episode->id,
                    'media_id' => $media['id'],
                ]);
            }

            // Step 2: Create audio extraction task
            $audioTask = $ittybit->createAudioTask(
                fileId: $this->episode->ittybit_file_id,
                options: [
                    'format' => 'mp3',
                    'bitrate' => 192000, // 192kbps - sweet spot for podcasts
                    'channels' => 2,
                    'ref' => 'podcast_audio',
                ]
            );

            ProcessingTask::create([
                'episode_id' => $this->episode->id,
                'ittybit_task_id' => $audioTask['id'],
                'type' => 'audio_extraction',
                'status' => $audioTask['status'],
            ]);

            Log::info("Created audio task", [
                'episode_id' => $this->episode->id,
                'task_id' => $audioTask['id'],
            ]);

            // Step 3: Create video transcoding task
            $videoTask = $ittybit->createVideoTask(
                fileId: $this->episode->ittybit_file_id,
                options: [
                    'format' => 'mp4',
                    'codec' => 'h264',
                    'width' => 1920,
                    'height' => 1080,
                    'fps' => 30,
                    'bitrate' => 5000000, // 5Mbps
                    'ref' => 'youtube_video',
                ]
            );

            ProcessingTask::create([
                'episode_id' => $this->episode->id,
                'ittybit_task_id' => $videoTask['id'],
                'type' => 'video_transcode',
                'status' => $videoTask['status'],
            ]);

            Log::info("Created video task", [
                'episode_id' => $this->episode->id,
                'task_id' => $videoTask['id'],
            ]);

            // Step 4: Create transcript task
            $transcriptTask = $ittybit->createTranscriptTask(
                fileId: $this->episode->ittybit_file_id,
                options: [
                    'language' => 'en',
                    'ref' => 'transcript',
                ]
            );

            ProcessingTask::create([
                'episode_id' => $this->episode->id,
                'ittybit_task_id' => $transcriptTask['id'],
                'type' => 'transcript_generation',
                'status' => $transcriptTask['status'],
            ]);

            Log::info("Created transcript task", [
                'episode_id' => $this->episode->id,
                'task_id' => $transcriptTask['id'],
            ]);

            // Step 5: Update episode status
            $this->episode->update([
                'status' => 'processing',
                'processing_started_at' => now(),
            ]);

            DB::commit();

            Log::info("Episode processing initiated successfully", [
                'episode_id' => $this->episode->id,
                'tasks_created' => 3,
            ]);

        } catch (\Exception $e) {
            DB::rollBack();

            Log::error("Failed to initiate episode processing", [
                'episode_id' => $this->episode->id,
                'error' => $e->getMessage(),
            ]);

            $this->episode->update([
                'status' => 'failed',
            ]);

            throw $e;
        }
    }
}

Let me walk you through what's happening here:

The transaction wrapper - We wrap everything in a database transaction because we're creating multiple related records. If creating the third task fails, we don't want the first two hanging around orphaned.

Creating the media object - This associates our uploaded file with a media container in Ittybit. Think of it like creating a project that all related files belong to.

Three parallel tasks - We're creating three tasks that Ittybit will process in parallel:

  • Audio extraction (8-12 minutes for a 60-min video)
  • Video transcoding (10-15 minutes)
  • Transcript generation (5-8 minutes)

The status flow - We're moving from uploadedprocessing. Later, when all tasks complete, we'll move to completed.

Error handling - If anything fails, we roll back, mark the episode as failed, and rethrow (so Laravel's queue system knows to retry).

The Webhook Handler: Receiving Completion Notifications

Here's where it gets really interesting. Ittybit doesn't make you poll for task status like it's 2005. Instead, it sends you a webhook when tasks complete. You give it a URL, and it POSTs to it.

First, let's add the webhook URL to our config:

// config/services.php

return [
    // ... other services ...

    'ittybit' => [
        'api_key' => env('ITTYBIT_API_KEY'),
        'webhook_url' => env('APP_URL') . '/webhooks/ittybit',
    ],
];

Now let's build the webhook controller:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Episode;
use App\Models\ProcessingTask;
use App\Jobs\CompleteEpisodeProcessingJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhooksController extends Controller
{
    /**
     * Handle incoming webhooks from Ittybit
     *
     * POST /webhooks/ittybit
     */
    public function ittybit(Request $request): JsonResponse
    {
        // Log the incoming webhook
        Log::info('Received Ittybit webhook', [
            'payload' => $request->all(),
        ]);

        // In production, you should verify the webhook signature here
        // Ittybit includes a signature in the headers that you can verify
        // to ensure the request is actually from Ittybit

        $payload = $request->all();

        // Get the task ID from the payload
        $taskId = $payload['id'] ?? null;
        $status = $payload['status'] ?? null;
        $kind = $payload['kind'] ?? null;

        if (!$taskId || !$status) {
            Log::warning('Invalid webhook payload', [
                'payload' => $payload,
            ]);

            return response()->json(['error' => 'Invalid payload'], 400);
        }

        // Find the processing task
        $task = ProcessingTask::where('ittybit_task_id', $taskId)->first();

        if (!$task) {
            Log::warning('Received webhook for unknown task', [
                'task_id' => $taskId,
            ]);

            // Return 200 anyway - we don't want Ittybit to retry
            return response()->json(['status' => 'ignored']);
        }

        // Update task status
        $updates = ['status' => $status];

        // If completed, extract the output file information
        if ($status === 'completed' && isset($payload['output'])) {
            $output = $payload['output'];
            $updates['output_file_id'] = $output['id'] ?? null;
            $updates['output_url'] = $output['url'] ?? null;
            $updates['metadata'] = $output;
        }

        // If failed, store error message
        if ($status === 'failed' && isset($payload['error'])) {
            $updates['error_message'] = $payload['error'];
        }

        $task->update($updates);

        Log::info('Updated processing task', [
            'task_id' => $taskId,
            'status' => $status,
            'type' => $task->type,
        ]);

        // Check if all tasks for this episode are complete
        $episode = $task->episode;
        $allTasks = $episode->processingTasks;

        $completedCount = $allTasks->where('status', 'completed')->count();
        $failedCount = $allTasks->where('status', 'failed')->count();
        $totalCount = $allTasks->count();

        Log::info('Episode task status', [
            'episode_id' => $episode->id,
            'completed' => $completedCount,
            'failed' => $failedCount,
            'total' => $totalCount,
        ]);

        // If all tasks are complete (or failed), finalize the episode
        if ($completedCount + $failedCount === $totalCount) {
            if ($failedCount > 0) {
                // Some tasks failed
                $episode->update(['status' => 'failed']);

                Log::error('Episode processing failed', [
                    'episode_id' => $episode->id,
                    'failed_tasks' => $failedCount,
                ]);
            } else {
                // All tasks completed successfully
                CompleteEpisodeProcessingJob::dispatch($episode);

                Log::info('All tasks complete, dispatching completion job', [
                    'episode_id' => $episode->id,
                ]);
            }
        }

        return response()->json([
            'status' => 'received',
            'task_id' => $taskId,
        ]);
    }
}

This webhook handler does several critical things:

1. Finds the task - We look up which of our tasks this webhook is about.

2. Updates status - We record the new status (completed, failed, etc.)

3. Extracts output - If the task completed, we save the output file ID and URL.

4. Checks completion - We count how many tasks are done. If all tasks for an episode are complete, we move to the next phase.

5. Returns quickly - We respond with 200 OK within a second or two. We don't do heavy processing in the webhook handler itself - we dispatch another job for that.

Why dispatch another job instead of doing the work right here? Because webhook handlers should be fast. Ittybit expects a response quickly. If you take too long, they might retry the webhook, and then you're processing the same event twice. Not fun.

The Completion Job: Gathering Results

When all tasks finish, we dispatch CompleteEpisodeProcessingJob. This job gathers all the results and updates the episode:

<?php

namespace App\Jobs;

use App\Models\Episode;
use App\Services\IttybitService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class CompleteEpisodeProcessingJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Episode $episode
    ) {}

    public function handle(IttybitService $ittybit): void
    {
        Log::info("Completing episode processing", [
            'episode_id' => $this->episode->id,
        ]);

        // Get all completed tasks
        $audioTask = $this->episode->processingTasks()
            ->where('type', 'audio_extraction')
            ->where('status', 'completed')
            ->first();

        $videoTask = $this->episode->processingTasks()
            ->where('type', 'video_transcode')
            ->where('status', 'completed')
            ->first();

        $transcriptTask = $this->episode->processingTasks()
            ->where('type', 'transcript_generation')
            ->where('status', 'completed')
            ->first();

        // Update episode with output files
        $updates = [
            'status' => 'processed',
            'processing_completed_at' => now(),
        ];

        if ($audioTask) {
            $updates['audio_file_id'] = $audioTask->output_file_id;
            $updates['audio_url'] = $audioTask->output_url;
        }

        if ($videoTask) {
            $updates['video_file_id'] = $videoTask->output_file_id;
            $updates['video_url'] = $videoTask->output_url;
        }

        if ($transcriptTask) {
            // Fetch the transcript file content
            $transcriptFile = $ittybit->getFile($transcriptTask->output_file_id);

            // Download and store the transcript
            // The transcript is typically returned as a VTT or JSON file
            // We'll fetch it and store the text
            $transcriptContent = file_get_contents($transcriptFile['url']);
            $updates['transcript'] = $transcriptContent;
        }

        $this->episode->update($updates);

        Log::info("Episode processing completed", [
            'episode_id' => $this->episode->id,
            'audio_ready' => isset($updates['audio_file_id']),
            'video_ready' => isset($updates['video_file_id']),
            'transcript_ready' => isset($updates['transcript']),
        ]);

        // In the next article, we'll dispatch upload jobs here
        // UploadToTransistorJob::dispatch($this->episode);
        // UploadToYouTubeJob::dispatch($this->episode);
    }
}

Now the episode has everything it needs: processed audio, processed video, and a transcript. In the next article, we'll upload these to Transistor and YouTube. But for now, we have a complete processing pipeline.

Adding a Status Endpoint

Users need a way to check on their episodes. Let's enhance our show() method to return rich status information:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Episode;
use App\Services\IttybitService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class EpisodesController extends Controller
{
    // ... previous methods ...

    /**
     * Get episode with detailed processing status
     *
     * GET /episodes/{id}
     */
    public function show(Request $request, string $id): JsonResponse
    {
        $episode = Episode::with('processingTasks')
            ->where('id', $id)
            ->where('user_id', $request->user()->id)
            ->firstOrFail();

        // Calculate overall progress
        $tasks = $episode->processingTasks;
        $totalTasks = $tasks->count();
        $completedTasks = $tasks->whereIn('status', ['completed', 'failed'])->count();

        $progress = $totalTasks > 0
            ? round(($completedTasks / $totalTasks) * 100)
            : 0;

        return response()->json([
            'id' => $episode->id,
            'title' => $episode->title,
            'description' => $episode->description,
            'status' => $episode->status,
            'progress' => $progress,
            'created_at' => $episode->created_at,
            'processing_started_at' => $episode->processing_started_at,
            'processing_completed_at' => $episode->processing_completed_at,

            // Processing tasks detail
            'tasks' => $tasks->map(function ($task) {
                return [
                    'type' => $task->type,
                    'status' => $task->status,
                    'error' => $task->error_message,
                    'output_url' => $task->output_url,
                    'updated_at' => $task->updated_at,
                ];
            }),

            // Outputs (when available)
            'outputs' => [
                'audio_url' => $episode->audio_url,
                'video_url' => $episode->video_url,
                'transcript' => $episode->transcript ? 'available' : null,
            ],
        ]);
    }
}

Now when a client hits GET /episodes/{id}, they get a complete picture:

{
  "id": "9b1f4c4e-5e8a-4f0f-8e3a-7c2b9d3f1e2a",
  "title": "How to Build APIs That Don't Suck",
  "status": "processing",
  "progress": 66,
  "processing_started_at": "2025-01-15T14:32:10Z",
  "tasks": [
    {
      "type": "audio_extraction",
      "status": "completed",
      "output_url": "https://you.ittybit.net/episodes/.../audio.mp3"
    },
    {
      "type": "video_transcode",
      "status": "completed",
      "output_url": "https://you.ittybit.net/episodes/.../video.mp4"
    },
    {
      "type": "transcript_generation",
      "status": "processing",
      "output_url": null
    }
  ]
}

Beautiful. The client can poll this endpoint to show progress to the user.

The Episode Model: Relationships

Let's add the relationship to our Episode model so the code above works:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Episode extends Model
{
    use HasFactory, HasUuids;

    protected $fillable = [
        'user_id',
        'title',
        'description',
        'original_filename',
        'file_size',
        'ittybit_folder',
        'ittybit_file_id',
        'ittybit_media_id',
        'audio_file_id',
        'audio_url',
        'video_file_id',
        'video_url',
        'transcript',
        'status',
        'processing_started_at',
        'processing_completed_at',
    ];

    protected $casts = [
        'file_size' => 'integer',
        'processing_started_at' => 'datetime',
        'processing_completed_at' => 'datetime',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function processingTasks(): HasMany
    {
        return $this->hasMany(ProcessingTask::class);
    }
}

Testing the Flow

Let's walk through what actually happens when you use this system:

1. Upload initiation

POST /episodes/prepare-upload
{
  "filename": "episode-042.mp4",
  "title": "How to Build APIs",
  "filesize": 2147483648
}

2. Client uploads to Ittybit (direct upload, bypassing our server)

3. Confirm upload

POST /episodes/{id}/confirm-upload
{
  "ittybit_file_id": "file_abc123"
}

At this point, ProcessEpisodeJob runs. It creates three tasks in Ittybit and saves them to our database. Episode status: processing.

4. Ittybit processes (5-15 minutes)

During this time, you can poll:

GET /episodes/{id}

And get progress updates as tasks complete.

5. Webhooks arrive (as each task finishes)

POST /webhooks/ittybit
{
  "id": "task_xyz789",
  "status": "completed",
  "output": {
    "id": "file_output123",
    "url": "https://you.ittybit.net/..."
  }
}

Our webhook handler updates the task status. When all three tasks are done, it dispatches CompleteEpisodeProcessingJob.

6. Episode is ready

GET /episodes/{id}
{
  "status": "processed",
  "progress": 100,
  "outputs": {
    "audio_url": "https://...",
    "video_url": "https://...",
    "transcript": "available"
  }
}

What We've Built

Let's take stock. We now have:

A job that creates multiple processing tasks - Audio, video, transcript ✅ Task tracking in the database - We know what's running and what's done ✅ Webhook handling - Ittybit tells us when tasks complete ✅ Progress tracking - Clients can see real-time progress ✅ Error handling - Failed tasks are logged and reported ✅ Async processing - Everything runs in the background

This is the foundation of any serious media processing application. You fire off work, return immediately, and handle results asynchronously. No timeouts. No blocking. Just clean, scalable workflows.

The DIY Comparison: Why This Matters

Let me tell you what this would look like if you tried to build it yourself without Ittybit:

Infrastructure you'd need:

  • FFmpeg servers (at least 2-3 for redundancy)
  • Queue system (we already have this)
  • Storage system (S3 or similar)
  • CDN for delivery
  • Speech-to-text service (AWS Transcribe, Google Speech-to-Text, etc.)

Code you'd write:

  • FFmpeg wrapper scripts
  • Upload/download management
  • Format detection and validation
  • Error handling for dozens of edge cases
  • Progress tracking
  • Resource management (preventing server overload)

Time investment: 4-6 weeks minimum. And that's just to get it working. Then you have to maintain it. FFmpeg updates. Server management. Scaling issues. It never ends.

With Ittybit: The code we've written today. A service class and two jobs. Maybe 300 lines total. And it's production-ready.

The cost savings aren't just in development time - they're in the mental overhead you don't have to carry. I can focus on building features users care about instead of debugging why FFmpeg is crashing on certain MP4 variants.

What's Next

In the next article, we'll tackle distribution. We have processed audio and video sitting in Ittybit. Now we need to get them to their final destinations:

  • Upload audio to Transistor (podcast hosting)
  • Upload video to YouTube
  • Handle OAuth flows
  • Manage API rate limits
  • Retry strategies

We'll build UploadToTransistorJob and UploadToYouTubeJob, and our episodes will finally be published automatically. The finish line is in sight.

Until then, I'd love to hear: have you built async processing systems before? What challenges did you run into? Hit me up on Twitter or drop a comment below.