INITIALIZING

Turning Hours of Gamer Rants into Searchable Gold with n8n and Supabase

Turning Hours of Gamer Rants into Searchable Gold with n8n and Supabase

Introduction

Besides writing about software architecture and tinkering with homelabs, I also run a podcast: GamerTV Podcast.

It's a Spanish-language podcast about videogames, told from the perspective of two 40-something grumpy gamers — yours truly (Darth Seldon) and my co-host Mr. Chilangiux. We rant, reminisce, and occasionally, against all odds, say something that resembles a coherent point about the gaming world.

But here's the problem: podcasts are fantastic to listen to, and genuinely impossible to search. When you want to find that one spicy rant about Final Fantasy VII remakes, or that deep dive into retro emulation you know we recorded... good luck scrubbing through hours of two grown men yelling about chocobos to find it.

That's why we're building this workflow: to automate transcription and storage of our episodes using n8n, so later we can layer on semantic search and finally make our own content discoverable — to us, mostly, since we're the ones who keep forgetting what we said.

This post builds on our earlier article, Build Your Own Local AI Automation Hub with n8n + Ollama, where we set up local Docker containers for n8n and Ollama. Now we're extending that stack with Whisper ASR transcription and Supabase vector storage — because apparently one homelab automation post is never enough, it's a lifestyle now.

Why n8n?

If you've ever played with Zapier or IFTTT, you know automation tools can glue services together. n8n takes that idea and gives it steroids and a self-hosting license:

  • Open source.
  • Self-hosted with Docker, so nobody can quietly sunset the free tier on you.
  • Extensible with dozens of nodes to connect APIs, databases, and services, most of which I discover exist mid-project, out of desperation.

For this project, we're using n8n nodes for:

  • GitHub Node — fetches new audio files and commits transcripts.
  • Airtable Node — logs progress, saves settings.
  • HTTP Request Node — calls Whisper ASR and Ollama APIs.
  • Function Node (JavaScript) — custom logic (checking file extensions, chunking transcripts).
  • Supabase Node — stores transcripts and embeddings.
  • File Node — handles .mp3, .txt, .srt data streams.

The real magic? You design the workflow visually, and n8n executes it step by step, without you personally babysitting a cron job at 2am like it's a newborn.

Setting up Whisper ASR with Docker Compose

We'll use a lightweight Whisper ASR web service container. Add this to your docker-compose.yml alongside your existing n8n and Ollama containers:

services:
  whisper-asr:
    image: rhasspy/whisper-asr-webservice:latest
    container_name: whisper-asr
    ports:
      - "9000:9000"
    environment:
      - ASR_MODEL=base
    restart: unless-stopped

This exposes an HTTP API at http://host.docker.internal:9000/asr. n8n's HTTP Request node can send .mp3 files and get back transcripts in .txt or .srt — the machine equivalent of politely asking someone to “just write down what we said,” except it actually does it, and doesn't complain about our audio quality.

Example call from n8n:

POST http://host.docker.internal:9000/asr?task=transcribe&language=es&output=txt

But you can also use the OpenAPI URL: http://localhost:9000/docs — genuinely useful if you'd rather click buttons than memorize query parameters like a masochist.

Using Ollama for Embeddings

From our previous post, we already have Ollama running in Docker. This time, instead of using it for chat, we'll call the embedding API with the Llama3.1:8b model — same tool, completely different job, like discovering your hammer is also, somehow, a very good letter opener.

POST http://host.docker.internal:11434/api/embeddings
Content-Type: application/json

{
  "model": "llama3.1:8b",
  "input": "This is a transcript chunk."
}

Or you can use n8n's Supabase vector node directly with Ollama embeddings baked in. Note that you can swap in any embedding model (OpenAI comes to mind), but you'll need to adjust your chunk sizes accordingly — more on that another time, filed under “problems I will absolutely have later.”

These embeddings get stored in Supabase via the pgvector extension.

Airtable Setup

Airtable is our tracker and settings store:

  • Settings Table — model names, thresholds, API keys.
  • Episodes Table — episode ID, filename, language, processed flags.
  • Progress Table — workflow runs, transcript/vectorization status.

n8n updates Airtable at every stage, so we always know which episodes are ready without either of us having to remember anything, which, given our track record, is the whole point.

Setting up Airtable itself is simple enough that I won't bore you with the click-by-click — but if enough of you actually want it, I'll come back and write it up properly.

Supabase Setup

Supabase is our storage backend. Create a new project and run this in the SQL editor:

-- Enable the pgvector extension to work with embedding vectors
create extension vector;

-- Create a table to store your documents
create table documents (
  id bigserial primary key,
  content text, -- corresponds to Document.pageContent
  metadata jsonb, -- corresponds to Document.metadata
  embedding vector(1536) -- 1536 works for OpenAI embeddings, change if needed
);

-- Create a function to search for documents
create function match_documents (
  query_embedding vector(1536),
  match_count int default null,
  filter jsonb DEFAULT '{}'
) returns table (
  id bigint,
  content text,
  metadata jsonb,
  similarity float
)
language plpgsql
as $$
#variable_conflict use_column
begin
  return query
  select
    id,
    content,
    metadata,
    1 - (documents.embedding <=> query_embedding) as similarity
  from documents
  where metadata @> filter
  order by documents.embedding <=> query_embedding
  limit match_count;
end;
$$;

This table stores transcripts as documents content, metadata (episodeId, timestamps, etc.), and embeddings — plus a function to actually search over all of it, which is the entire point of this exercise and not just an elaborate way to back up podcast audio.

Relational, Vector, and Node Databases

Since we're mixing approaches, here's a quick cheat sheet:

  • Relational (Postgres, MySQL) → structured metadata and transcripts.
  • Vector (pgvector, Pinecone) → semantic embeddings for similarity.
  • Node/Graph (Neo4j) → relationships, like who appeared in which episodes.

Supabase with pgvector lets us combine relational and vector in one database, which is the database equivalent of a Swiss Army knife that doesn't also stab you when you close it wrong.

Rather than dump the full workflow export here (it's a genuinely enormous JSON file once you include every node, position, and credential placeholder — nobody wants to scroll through that, including me, and I wrote it), here's the actual logic behind the Function Node that decides which episodes still need work, the piece doing the real thinking in this whole pipeline:

const allFiles = [];
const mediaFiles = [];
const companionFiles = new Map(); // Map to track TXT/SRT companions
const mediaCompanions = new Map(); // Map to track MP3/MP4 companions

// Collect and categorize all files
for (const item of $input.all()) {
  const tree = item.json.tree;
  
  tree.forEach(file => {
    if (file.type === 'blob') {
      const filePath = file.path;
      const fileName = filePath.split('/').pop();
      const directory = filePath.substring(0, filePath.lastIndexOf('/'));
      const extension = fileName.toLowerCase().split('.').pop();
      const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'));
      
      const fileObj = {
        path: filePath,
        directory: directory,
        fileName: fileName,
        nameWithoutExt: nameWithoutExt,
        extension: extension,
        sha: file.sha,
        url: file.url,
        size: file.size
      };
      
      // Collect MP3
      if (extension === 'mp3') {
        mediaFiles.push(fileObj);
        
        // Track media companions (MP3/MP4 pairs)
        const baseKey = `${directory}/${nameWithoutExt}`;
        if (!mediaCompanions.has(baseKey)) {
          mediaCompanions.set(baseKey, []);
        }
        mediaCompanions.get(baseKey).push(extension);
      }
      
      // Track subtitle companions (TXT/SRT)
      if (extension === 'txt' || extension === 'srt') {
        const baseKey = `${directory}/${nameWithoutExt}`;
        if (!companionFiles.has(baseKey)) {
          companionFiles.set(baseKey, []);
        }
        companionFiles.get(baseKey).push(extension);
      }
    }
  });
}

// Find media files with missing companions
const mediaWithMissingCompanions = mediaFiles.filter(media => {
  const baseKey = `${media.directory}/${media.nameWithoutExt}`;
  const subtitleCompanions = companionFiles.get(baseKey) || [];
  const mediaCompanionsList = mediaCompanions.get(baseKey) || [];
  
  // Check if missing subtitle files OR missing media companion
  const missingSubtitles = !subtitleCompanions.includes('txt') && !subtitleCompanions.includes('srt');
  const missingMediaCompanion = (media.extension === 'mp3' && !mediaCompanionsList.includes('mp4')) ||
                                (media.extension === 'mp4' && !mediaCompanionsList.includes('mp3'));
  
  // Return true if missing any companion
  return missingSubtitles || missingMediaCompanion;
}).map(media => {
  const baseKey = `${media.directory}/${media.nameWithoutExt}`;
  const subtitleCompanions = companionFiles.get(baseKey) || [];
  const mediaCompanionsList = mediaCompanions.get(baseKey) || [];
  
  return {
    json: {
      ...media,
      mediaType: media.extension.toUpperCase(),
      missingCompanions: {
        // Subtitle companions
        hasTxt: subtitleCompanions.includes('txt'),
        hasSrt: subtitleCompanions.includes('srt'),
        missingSubtitles: !subtitleCompanions.includes('txt') && !subtitleCompanions.includes('srt'),
        
        // Media companions
        hasMp3: mediaCompanionsList.includes('mp3'),
        hasMp4: mediaCompanionsList.includes('mp4'),
        missingMp3: media.extension === 'mp4' && !mediaCompanionsList.includes('mp3'),
        missingMp4: media.extension === 'mp3' && !mediaCompanionsList.includes('mp4'),
        
        // Summary
        existingSubtitleCompanions: subtitleCompanions,
        existingMediaCompanions: mediaCompanionsList,
        missingTypes: [
          ...(!subtitleCompanions.includes('txt') && !subtitleCompanions.includes('srt') ? ['subtitles'] : []),
          ...(media.extension === 'mp3' && !mediaCompanionsList.includes('mp4') ? ['mp4'] : []),
          ...(media.extension === 'mp4' && !mediaCompanionsList.includes('mp3') ? ['mp3'] : [])
        ]
      }
    }
  };
});

return mediaWithMissingCompanions;

It walks the full GitHub file tree, groups files by their base name (so episode12.mp3, episode12.txt, and episode12.srt all get recognized as the same episode), and flags anything missing a transcript or subtitle companion. That's the entire “is this episode done yet” check, handled by one function, instead of either of us keeping a mental tally we would definitely get wrong.

Workflow Walkthrough

  • GitHub List Repo Files — scan repository for .mp3 files.
  • Function Node — check if transcripts (.txt, .srt) are missing.
  • Airtable Lookup/Create — register the episode.
  • Whisper ASR (HTTP Request Nodes) — transcribe audio into TXT and SRT.
  • GitHub Commit — push transcripts back into the repo.
  • Ollama Embeddings — create embeddings with Llama3.1:8b.
  • Supabase Insert — persist transcript, metadata, embeddings.
  • Airtable Update — mark episode as Transcribed and Vectorized.

Architecture

We'll look at this from two perspectives, because one diagram is never enough to make an architecture feel official:

  • C4 Architecture Diagram — systems and their relationships.
  • n8n Flow Diagram — execution path inside n8n.

C4 Architecture Diagram

@startuml
title Podcast Ingestion Pipeline with n8n

!include <C4/C4_Container>

AddContainerTag("worker", $legendText="Worker/Automation")
AddContainerTag("store",  $legendText="Storage")
AddContainerTag("tracker",$legendText="Tracker/Config")
AddContainerTag("ml",     $legendText="ML/Embeddings")

Person(admin, "Maintainer", "Ops/dev who runs the pipeline")

System_Boundary(n8n, "n8n Workflow") {
  Container(githubNode, "GitHub Node", "Fetch/commit audio & transcripts", $tags="worker")
  Container(airtableNode, "Airtable Node", "Track progress", $tags="tracker")
  Container(whisperNode, "HTTP Whisper ASR Nodes", "Send audio → Get TXT/SRT", $tags="ml")
  Container(ollamaNode, "Ollama Node", "Generate embeddings (Llama3.1:8b)", $tags="ml")
  Container(supabaseNode, "Supabase Node", "Insert transcripts + embeddings", $tags="store")
}
@enduml
C4 architecture diagram: n8n workflow with GitHub, Airtable, Whisper ASR, Ollama, and Supabase nodes

n8n Workflow Flow Diagram

n8n workflow flow diagram showing the podcast ingestion pipeline steps in order

You'll need to swap in your own username, repo, Airtable table names, and Supabase/GitHub credentials — just import the workflow and update those settings. You can also cherry-pick individual nodes if you only need part of this, no shame in taking the parts that work and quietly ignoring the rest.

Conclusion

With n8n as the automation hub, plus Airtable, Supabase, Whisper ASR, and Ollama, we now have a fully automated ingestion pipeline for our podcast:

  • n8n orchestrates GitHub, Airtable, Whisper, and Supabase nodes.
  • Airtable tracks state and progress.
  • Whisper ASR provides high-quality transcripts.
  • Ollama generates embeddings.
  • Supabase stores text and vectors.

This workflow makes it easy to transcribe and enrich every episode automatically, freeing us from the extremely glamorous task of manually re-listening to ourselves to remember what we said.

Next up: a dedicated post on how to actually search this vector database with n8n — turning this ingestion pipeline into a full-blown semantic podcast search engine, so we can finally answer “wait, which episode did we talk about that in” without descending into an argument about it on air.

Happy Coding!!!