Building a Podcast Automation Platform with Ittybit

View Markdown

Part 1 - Architecture & File Ingestion

I've spent the last few years watching podcasters wrestle with the same problem over and over: they record an amazing conversation, then spend the next 2-3 hours manually processing it. Extract the audio. Optimize the video. Upload to multiple platforms. Generate transcripts. Write show notes. It's tedious, it's repetitive, and frankly, it's exactly the kind of work computers should be doing for us.

So I decided to build something about it. Not just a tool, but a complete exploration of how modern media processing APIs can eliminate this drudgery. This is the first article in a series where we'll build a real podcast automation platform using Ittybit's media processing API.

The Problem (And Why You Should Care)

Here's what a typical podcast workflow looks like today:

  1. Record a 60-minute video interview on StreamYard
  2. Download a 2GB video file
  3. Fire up Audacity or Adobe Audition to extract audio
  4. Export as MP3, fiddle with bitrate settings
  5. Upload to Transistor or Libsyn
  6. Open Premiere or Handbrake for video optimization
  7. Upload to YouTube (and wait... and wait...)
  8. Use another service for transcription
  9. Manually write show notes from the transcript

Each episode. Every single time.

If you're running a weekly show, you're spending 8-12 hours per month on pure mechanical work. If you've ever thought "there has to be a better way," well, there is. And we're going to build it.

What We're Building: The 10,000-Foot View

Our goal is simple: upload once, publish everywhere, automatically.

Here's what the final product will do:

INPUT:  raw_podcast_video.mp4 (2.5GB, 1080p)

OUTPUT:
  ✓ Optimized MP3 on Transistor (podcast platforms)
  ✓ Optimized MP4 on YouTube
  ✓ Full transcript with timestamps
  ✓ AI-generated show notes
  ✓ Chapter markers
  ✓ All in ~10 minutes, hands-off

And we're building this as a pure REST API. No frontend, no Livewire, no React. Just clean Laravel API endpoints that handle the heavy lifting. Why? Because:

  1. APIs are universal - Any client can consume them
  2. APIs force good architecture - You can't hide bad design behind UI tricks
  3. APIs are easier to test - No DOM, no JavaScript, just HTTP
  4. You can always add a UI later - But you can't easily extract an API from a monolith

The Architecture: API-First Design

Let me show you how this system is structured. I'm a big believer in understanding the "why" before the "how," so let's walk through the key architectural decisions.

The Stack

┌─────────────────────────────────────┐
│      Laravel 12 API                 │
│                                     │
│  ┌──────────────────────────────┐   │
│  │  API Routes (routes/api.php) │   │
│  └──────────────┬───────────────┘   │
│                 │                   │
│  ┌──────────────▼───────────────┐   │
│  │  Controllers                 │   │
│  │  - EpisodesController        │   │
│  │  - WebhooksController        │   │
│  └──────────────┬───────────────┘   │
│                 │                   │
│  ┌──────────────▼───────────────┐   │
│  │  Queue Jobs (Redis)          │   │
│  │  - ProcessEpisodeJob         │   │
│  │  - UploadToTransistorJob     │   │
│  │  - UploadToYouTubeJob        │   │
│  └──────────────┬───────────────┘   │
│                 │                   │
│  ┌──────────────▼───────────────┐   │
│  │  Services                    │   │
│  │  - IttybitService            │   │
│  │  - TransistorService         │   │
│  │  - YouTubeService            │   │
│  └──────────────────────────────┘   │
└─────────────────────────────────────┘

      ┌───────────┼───────────┐
      │           │           │
      ▼           ▼           ▼
┌─────────┐ ┌──────────┐ ┌─────────┐
│ Ittybit │ │Transistor│ │ YouTube │
│   API   │ │   API    │ │   API   │
└─────────┘ └──────────┘ └─────────┘

Key architectural choices:

  1. Stateless API - Every request is independent, fully authenticated
  2. Queue-driven processing - Long-running tasks don't block HTTP requests
  3. Webhook receivers - We listen for external events (Ittybit completion, etc.)
  4. Service layer - Business logic lives in dedicated service classes, not controllers
  5. Event-driven - Laravel events tie the system together without tight coupling

The Flow: From Upload to Publishing

Here's what happens when someone uploads a video:

1. POST /episodes
   └─> Create episode record (status: "uploaded")
   └─> Dispatch ProcessEpisodeJob
   └─> Return 201 Created with an episode ID

2. ProcessEpisodeJob runs (background)
   └─> Upload file to Ittybit
   └─> Create processing tasks (audio, video, transcript)
   └─> Update episode status: "processing"

3. Ittybit processes media (5-10 minutes)
   └─> Extract audio → MP3
   └─> Transcode video → MP4 (1080p)
   └─> Generate transcript

4. POST /webhooks/ittybit (Ittybit calls us)
   └─> Verify webhook signature
   └─> Update task status in database
   └─> Dispatch upload jobs when ready

5. UploadToTransistorJob & UploadToYouTubeJob
   └─> Download processed files from Ittybit
   └─> Upload to respective platforms
   └─> Store episode URLs

6. Episode complete!
   └─> Update status: "published"
   └─> Send notification

This architecture gives us resilience (jobs retry on failure), scalability (add more queue workers), and observability (track every step).

Getting Files into Ittybit: The Foundation

Alright, enough theory. Let's write some code.

The first thing we need to do is get our video files into Ittybit. Looking at the Ittybit API docs, I can see two main approaches:

  1. Direct URL ingestion - Give Ittybit a publicly accessible URL
  2. Signed URL upload - Get a signed URL, upload directly from client

For our API, we'll use signed URLs. Why? Because:

  • We don't want to store huge video files on our server
  • Clients can upload directly to Ittybit's CDN (faster)
  • We save bandwidth and storage costs
  • It's more scalable

Setting Up Ittybit

First, let's create a service class to handle all Ittybit interactions:

<?php

namespace App\Services;

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

class IttybitService
{
    private string $apiKey;

    public function __construct(
        private string $apiKey;
        private string $baseUrl = 'https://api.ittybit.com',
    ) {}

    /**
     * Generate a signed URL for direct client upload
     */
    public function generateUploadSignature(
        string $filename,
        ?string $folder = null,
        int $expiryMinutes = 60
    ): array {
        $expiry = now()->addMinutes($expiryMinutes)->timestamp;

        $response = Http::withHeaders([
            'Authorization' => "Bearer {$this->apiKey}",
            'Accept-Version' => '2025-08-20',
        ])->post("{$this->baseUrl}/signatures", [
            'filename' => $filename,
            'folder' => $folder ?? 'podcast-episodes',
            'expiry' => $expiry,
            'method' => 'put',
        ]);

        if ($response->failed()) {
            Log::error('Failed to generate Ittybit signature', [
                'status' => $response->status(),
                'body' => $response->body(),
            ]);

            throw new \Exception('Failed to generate upload signature');
        }

        return $response->json();
    }

    /**
     * Create a media item from an uploaded file
     */
    public function createMediaFromFile(
        string $fileId,
        array $metadata = []
    ): array {
        $response = Http::withHeaders([
            'Authorization' => "Bearer {$this->apiKey}",
            'Accept-Version' => '2025-08-20',
        ])->post("{$this->baseUrl}/media", [
            'title' => $metadata['title'] ?? null,
            'alt' => $metadata['description'] ?? null,
            'metadata' => $metadata,
        ]);

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

            throw new \Exception('Failed to create media item');
        }

        return $response->json();
    }

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

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

        return $response->json();
    }
}

Let me break down what's happening here:

The generateUploadSignature() method creates a time-limited, signed URL that clients can use to upload files directly to Ittybit. This is crucial - we're not handling the file upload ourselves. We're just brokering the permission.

The createMediaFromFile() method tells Ittybit "hey, I just uploaded a file, create a media object for it." This is where we attach metadata like title and description.

The getFile() method fetches details about a specific file. We'll use this later to check processing status and get URLs to processed files.

The Episode Creation Endpoint

Now let's build our first API endpoint. This is where clients will initiate the upload process:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\IttybitService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class EpisodesController extends Controller
{
    public function __construct(
        private IttybitService $ittybit
    ) {}

    /**
     * Step 1: Client requests an upload URL
     *
     * POST /episodes/prepare-upload
     */
    public function prepareUpload(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'filename' => 'required|string|max:255',
            'title' => 'required|string|max:500',
            'description' => 'nullable|string',
            'filesize' => 'required|integer|min:1',
        ]);

        // Generate a unique folder for this episode
        $episodeId = (string) Str::uuid();
        $folder = "episodes/{$episodeId}";

        // Get signed URL from Ittybit
        $signature = $this->ittybit->generateUploadSignature(
            filename: $validated['filename'],
            folder: $folder,
            expiryMinutes: 120 // 2 hours to upload
        );

        // Create episode record (status: awaiting_upload)
        $episode = Episode::create([
            'id' => $episodeId,
            'user_id' => $request->user()->id,
            'title' => $validated['title'],
            'description' => $validated['description'],
            'original_filename' => $validated['filename'],
            'file_size' => $validated['filesize'],
            'ittybit_folder' => $folder,
            'status' => 'awaiting_upload',
        ]);

        return response()->json([
            'episode_id' => $episode->id,
            'upload_url' => $signature['url'],
            'expires_at' => $signature['expiry'],
            'instructions' => 'PUT your file to upload_url with Content-Type header',
        ], 201);
    }

    /**
     * Step 2: Client confirms upload is complete
     *
     * POST /episodes/{id}/confirm-upload
     */
    public function confirmUpload(Request $request, string $id): JsonResponse
    {
        $episode = Episode::where('id', $id)
            ->where('user_id', $request->user()->id)
            ->firstOrFail();

        if ($episode->status !== 'awaiting_upload') {
            return response()->json([
                'error' => 'Episode is not awaiting upload',
            ], 400);
        }

        $validated = $request->validate([
            'ittybit_file_id' => 'required|string',
        ]);

        // Update episode with Ittybit file ID
        $episode->update([
            'ittybit_file_id' => $validated['ittybit_file_id'],
            'status' => 'uploaded',
        ]);

        // Dispatch processing job
        ProcessEpisodeJob::dispatch($episode);

        return response()->json([
            'episode_id' => $episode->id,
            'status' => 'uploaded',
            'message' => 'Episode is now queued for processing',
        ]);
    }

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

        return response()->json($episode);
    }
}

How This Works in Practice

Here's the actual flow from a client perspective:

Step 1: Request upload permission

curl -X POST https://your-api.com/episodes/prepare-upload \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "episode-042.mp4",
    "title": "How to Build APIs That Dont Suck",
    "description": "A deep dive into API design",
    "filesize": 2147483648
  }'

Response:

{
  "episode_id": "9b1f4c4e-5e8a-4f0f-8e3a-7c2b9d3f1e2a",
  "upload_url": "https://you.ittybit.net/episodes/9b1f4c4e.../episode-042.mp4?signature=...",
  "expires_at": 1740394800,
  "instructions": "PUT your file to upload_url with Content-Type header"
}

Step 2: Upload the file directly to Ittybit

curl -X PUT "https://you.ittybit.net/episodes/9b1f4c4e.../episode-042.mp4?signature=..." \
  -H "Content-Type: video/mp4" \
  --data-binary "@episode-042.mp4"

Step 3: Confirm upload

curl -X POST https://your-api.com/episodes/9b1f4c4e.../confirm-upload \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "ittybit_file_id": "file_abcdefgh1234"
  }'

Response:

{
  "episode_id": "9b1f4c4e-5e8a-4f0f-8e3a-7c2b9d3f1e2a",
  "status": "uploaded",
  "message": "Episode is now queued for processing"
}

And just like that, the file is in Ittybit's system and ready for processing.

Why This Approach Wins

Let me tell you why I love this architecture:

1. Zero Server Storage - We never touch the video file. It goes straight from client → Ittybit. No need for massive EBS volumes or S3 buckets.

2. Fast Uploads - Clients upload directly to Ittybit's CDN, which is geographically distributed. Much faster than routing through our server.

3. Security - Signed URLs expire. No permanent upload endpoints that could be abused.

4. Scalability - We can handle 1 upload or 1,000 uploads with the same infrastructure. The bottleneck is Ittybit's capacity, not ours.

5. Clean Separation - Our API orchestrates the workflow. Ittybit handles the heavy media processing. Each service does what it's good at.

The Database Schema (So Far)

Here's what our episodes table looks like at this stage:

<?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('episodes', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');

            // Episode metadata
            $table->string('title');
            $table->text('description')->nullable();
            $table->string('original_filename');
            $table->bigInteger('file_size');

            // Ittybit references
            $table->string('ittybit_folder')->nullable();
            $table->string('ittybit_file_id')->nullable();
            $table->string('ittybit_media_id')->nullable();

            // Processing status
            $table->enum('status', [
                'awaiting_upload',
                'uploaded',
                'processing',
                'completed',
                'failed',
            ])->default('awaiting_upload');

            // We'll add more columns as we progress
            // (audio URLs, video URLs, transcript, etc.)

            $table->timestamps();

            $table->index(['user_id', 'status']);
            $table->index('created_at');
        });
    }

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

What's Next?

In the next article, we'll tackle the processing pipeline. We'll:

  • Create audio extraction tasks in Ittybit
  • Handle video transcoding for YouTube
  • Set up webhook receivers to know when tasks complete
  • Build the queueing system that ties it all together

But for now, we have a solid foundation: an API that can accept episode uploads, generate signed URLs for direct client uploads, and track the status of episodes. The groundwork is laid.

Try It Yourself

Want to experiment with this approach? Here's what you'll need:

  1. Ittybit Account - Sign up at ittybit.com (they have a free tier).
  2. Laravel 12 - Fresh installation.
  3. Redis - For queue management.
  4. Sqlite database - Simple local storage.
  5. A test video file - MP4, MOV, anything Ittybit supports.

The code we've written today gives you everything you need to start accepting video uploads and preparing them for processing. In the next article, we'll make those videos actually do something.

Until then, happy coding. And if you've built something similar (or are thinking about it), I'd love to hear about your approach. Hit me up on Twitter or leave a comment below.