Blog / For developers and API buyers
For developers and API buyers

Building a podcast recommendation engine with a search API

How to build "more like this", personalized home feeds and cold-start recommendations for a podcast app using catalog data, full-text search, genres, charts and Podcast 2.0 fields, with code.

Published by the PodSearch team

Recommendations are where podcast apps win or lose retention. A listener who finishes a series and is immediately offered three shows they will love keeps listening. A listener who sees the same ten chart toppers as everyone else drifts away. Yet most teams do not have the listening history of a large platform, so the collaborative-filtering approaches described in famous papers are out of reach on day one.

The good news is that podcasts are unusually rich in text. Every show and episode comes with a title, a description, often show notes, genres, a publisher, sometimes a transcript and a list of people. That makes content-based recommendation, built on search and metadata, a strong starting point. This guide shows how to build one with the podsearch.org API (ListenNotes-compatible, at https://podsearch.org/api/v2), then layer in behavioral signals as your user base grows.

Three kinds of recommendation you need

Most apps need three recommendation surfaces, and they call for different techniques.

  1. Item-to-item ("more like this"). On a show or episode page: "If you like this, try these." This is the easiest to build and the most used.
  2. User-to-item ("for you"). A personalized home feed based on what this listener follows and plays.
  3. Cold start. What to show a brand-new user with no history, and how to surface brand-new shows that have no listeners yet.

We will build each in turn.

Building blocks available from the API

Before designing algorithms, take stock of the raw material.

SignalWhere it comes fromEndpoint
Ready-made similar showsAPI-side similarityGET /podcasts/{id}/recommendations
Ready-made similar episodesAPI-side similarityGET /episodes/{id}/recommendations
GenresFeed categories mapped to genre IDsGET /genres, genre_ids on podcasts
Popular shows by genre and regionCurated and chart-based listsGET /best_podcasts
Text similarityFull-text search over titles, descriptions, contentGET /search
People<podcast:person> tags where feeds provide thempodcast and episode data
Recency and activitylatest_pub_date_ms, total_episodesGET /podcasts/{id}
A random good episodeSerendipityGET /just_listen

The fastest route to a working feature is to use the ready-made endpoints first and add your own logic around them.

Item-to-item: "more like this"

Start with the built-in endpoint

curl -s "https://podsearch.org/api/v2/podcasts/12345/recommendations?safe_mode=1" \
  -H "X-ListenAPI-Key: $PODSEARCH_API_KEY"

Replace 12345 with a podcast ID you got from search. The response is a recommendations list of up to eight podcast objects in the familiar format (id, title, publisher, image, rss, genre_ids and so on). Episode-level recommendations work the same way through /episodes/{id}/recommendations.

Use these as your baseline. They require no data of your own and give every show page a populated "more like this" row on launch day.

Improve it with your own re-ranking

Built-in recommendations are generic by design. You know things the API does not: which shows your users actually finish, which publishers your app features, which languages your audience speaks. A small re-ranking layer captures that.

import math, time

def rerank(candidates, *, user_langs=("en",), boost_ids=frozenset(), now_ms=None):
    now_ms = now_ms or int(time.time() * 1000)
    scored = []
    for rank, p in enumerate(candidates):
        score = 1.0 / (rank + 1)                      # keep the API's order as a prior
        lang = (p.get("language") or "").lower()
        if lang and not lang.startswith(user_langs):
            score *= 0.2                              # mostly drop other languages
        latest = p.get("latest_pub_date_ms") or 0
        days_idle = (now_ms - latest) / 86_400_000 if latest else 9999
        score *= 1.0 if days_idle < 30 else 0.6 if days_idle < 365 else 0.3
        if p["id"] in boost_ids:
            score *= 1.5                              # editorial or partner boost
        scored.append((score, p))
    return [p for _, p in sorted(scored, key=lambda t: -t[0])]

Two signals do a surprising amount of work here. Language prevents the jarring experience of a Portuguese show recommended to an English-only listener. Activity keeps you from recommending shows that stopped publishing years ago, which is a real issue in an open catalog where millions of feeds are dormant.

When you want more control, generate candidates yourself with full-text search. The idea: extract distinctive terms from the seed show and search for other shows that match them.

import re
from collections import Counter

STOP = set("the a an and or of to in for on with is are this that your you our we it be by at from as".split())

def key_terms(text, n=6):
    words = re.findall(r"[a-z][a-z'-]{2,}", (text or "").lower())
    counts = Counter(w for w in words if w not in STOP)
    return [w for w, _ in counts.most_common(n)]

def similar_shows(seed):
    terms = key_terms(seed["title"] + " " + seed.get("description", ""))
    q = " OR ".join(terms)
    res = api.get("/search", q=q, type="podcast", syntax="dql", page_size=30,
                  language=seed.get("language") or None)
    return [p for p in res["results"] if p["id"] != seed["id"]]

Term-frequency extraction is crude but effective, because show descriptions are short and dense with topical words. For better quality, compute TF-IDF weights over a sample of descriptions so that common words such as "podcast", "weekly" and "episode" are discounted automatically, or embed descriptions with a sentence-embedding model and compare vectors.

Use genres as a guardrail

Pure text similarity sometimes drifts: a true-crime show about a financial fraud can match business podcasts. Requiring at least one shared genre, or penalizing candidates with no genre overlap, keeps recommendations on-topic:

def genre_overlap(a, b):
    ga, gb = set(a.get("genre_ids") or []), set(b.get("genre_ids") or [])
    return len(ga & gb) / max(1, len(ga | gb))

People-based recommendations

Where feeds publish <podcast:person> tags, you can recommend along people: "More with this guest" or "Other shows from this host". Guest appearances are a strong signal because listeners often follow a person across shows. Search by name across episode text to find appearances even when a feed does not tag people:

def guest_appearances(name, limit=20):
    res = api.get("/search", q=f'"{name}"', type="episode", syntax="dql",
                  unique_podcasts=1, sort_by_date=1, page_size=limit)
    return res["results"]

Quoting the name makes it a phrase match, and unique_podcasts=1 returns at most one episode per show, so the result reads as a list of shows the person has been on.

User-to-item: a personalized home feed

Once a listener follows a few shows, you can blend item-to-item results into a personal feed.

  1. Take the user's recently followed and most-played shows as seeds, weighting recent activity more.
  2. For each seed, fetch recommendations (cached).
  3. Merge candidates, summing each candidate's score across seeds, so shows similar to several of the user's interests rise.
  4. Remove shows the user already follows or has dismissed.
  5. Diversify, so one genre or publisher does not fill the whole feed.
from collections import defaultdict

def home_feed(seed_ids, followed, dismissed, k=30):
    scores, items = defaultdict(float), {}
    for weight, sid in zip([1.0, 0.8, 0.6, 0.5, 0.4], seed_ids):
        recs = cached_get(f"/podcasts/{sid}/recommendations", ttl=86_400)
        for rank, p in enumerate(recs.get("recommendations", recs.get("results", []))):
            if p["id"] in followed or p["id"] in dismissed:
                continue
            scores[p["id"]] += weight / (rank + 1)
            items[p["id"]] = p
    ranked = sorted(items.values(), key=lambda p: -scores[p["id"]])
    return diversify(ranked, k)

def diversify(ranked, k, per_genre=4):
    out, seen = [], defaultdict(int)
    for p in ranked:
        g = (p.get("genre_ids") or [None])[0]
        if seen[g] < per_genre:
            out.append(p); seen[g] += 1
        if len(out) == k:
            break
    return out

The recommendations endpoint returns its list under a recommendations key; the snippet also accepts results so the same helper works with search responses. The helpers api, cached_get and last_n_days are the small client, cache and date utilities from our podcast search API guide.

Add fresh-episode rows

Personal feeds feel alive when they include new episodes, not just shows. For each followed show, GET /podcasts/{id} returns the latest episodes sorted newest first. Combine them into a "new from your shows" row. Freshness matters here: podsearch.org re-polls feeds that announce updates via Podping within seconds to minutes of publish, so this row can show today's episodes promptly.

Not every recommendation has to come from a seed show. If a user plays several episodes about, say, astronomy, a row of recent, well-matched episodes on that topic is a good recommendation:

def topic_row(topic, days=14):
    return api.get("/search", q=topic, type="episode", recency=1, quality=1,
                   unique_podcasts=1, published_after=last_n_days(days), page_size=10)["results"]

recency=1 boosts newer episodes, quality=1 prefers established shows and caps episodes per show, and unique_podcasts=1 keeps the row varied.

Cold start: new users and new shows

New users

With no history, ask. A two-tap onboarding screen ("pick three topics you like") gives you seeds immediately. Map each topic to a genre and fill the first screen with /best_podcasts:

curl -s "https://podsearch.org/api/v2/best_podcasts?genre_id=127&language=en&page_size=20" \
  -H "X-ListenAPI-Key: $PODSEARCH_API_KEY"

Get the genre IDs from GET /genres and use language (a language code such as en or es) to localize. podsearch.org also tracks Apple Podcasts charts per country for its browse pages, which is a good reference for what is popular in a market right now.

New shows

New shows have no listeners and no history, which means behavior-based systems never recommend them. Content-based methods do not have this problem: a new show with a clear title and description matches text similarity queries on day one. To deliberately surface new shows, reserve one or two slots per row for candidates whose first episode is recent and whose text similarity is high. Your users get discovery; independent creators get a fair chance.

Quality filters

A recommendation engine is only as good as its worst suggestion. Common filters:

Measuring whether it works

Track three numbers from the start:

Run changes as A/B tests on a slice of users. Recommendation quality is notoriously hard to judge by looking at a few examples; small, measured changes beat big redesigns.

A reference architecture

Putting the pieces together, a production recommendation system for a small or mid-sized podcast app usually has four parts.

A candidate generator. This is the code that calls the API: built-in recommendations for each seed show, text-similarity searches, topic searches and chart lists. It runs in a background worker, not in the request path, and writes candidates to your own database. Because it runs on a schedule, a slow or failed API call delays a refresh rather than breaking a page.

A feature store. For every candidate show you keep a handful of features: language, genre IDs, latest publish date, episode count, whether it has transcripts or people tags, and, from your own data, follow and completion rates. Most of these come straight from the podcast object; the rest come from your analytics.

A ranker. At first this is the hand-written scoring function shown earlier. Later it can be a learned model (a gradient-boosted tree is a common choice) trained on whether users followed or played what they were shown. Keep the hand-written version as a fallback and as a baseline to beat.

A serving layer. When the app asks for a home feed, the server reads precomputed rankings for that user, applies last-second filters (anything followed in the past hour, anything dismissed), and returns a page. This read should take a few milliseconds and never call the external API.

Separating generation from serving is the single most important design choice. It keeps latency low, makes cost predictable, and lets you change ranking logic without touching the code that talks to the API.

Explaining recommendations

Listeners trust recommendations they understand. A short reason under each suggestion, such as "Because you follow a history show", "With a guest from an episode you played" or "New this week in Science", measurably helps engagement in many products and costs you nothing, because your pipeline already knows why each candidate was generated. Store the reason alongside the candidate and render it.

Reasons also help your own team. When a recommendation looks wrong, the reason tells you which part of the pipeline produced it, which turns a vague complaint into a specific bug.

Privacy and listener trust

Listening history is personal. A few principles keep you on the right side of your users and of privacy law:

Recommendations that respect these boundaries are the ones users keep turning on.

Cost planning

Recommendations are read-heavy and cacheable. A show's "more like this" list changes slowly, so caching it for a day is reasonable. A personal feed can be computed in a background job once a day per active user, so request volume scales with daily active users rather than page views. On the Pro plan, batch endpoints (POST /podcasts with up to 10 IDs) cut the cost of hydrating candidate lists.

Frequently asked questions

Do I need machine learning to build podcast recommendations?

No. Content-based recommendations from text, genres and activity signals work well from day one. Add learned models later, once you have enough listening data to train them.

What is the quickest way to get "more like this" on every show page?

Call GET /podcasts/{id}/recommendations, cache the result for a day, and apply a light re-ranking for language and recency.

How do I recommend brand-new shows?

Use text similarity, which works without listening history, and reserve a slot or two per row for new shows with strong matches.

Can I recommend episodes by guest?

Yes. Search the guest's name as a quoted phrase with syntax=dql and unique_podcasts=1. Where feeds publish <podcast:person> tags, you can also use that structured data.

How do I avoid recommending dead podcasts?

Use latest_pub_date_ms to penalize shows that have not published recently, and treat complete limited series as an exception.

Summary

You do not need a giant listening graph to recommend podcasts well. Start with the built-in recommendation endpoints, re-rank for language and recency, add text and genre similarity from search, layer in people and topic rows, and handle cold start with onboarding and new-show slots. Measure everything, and let behavioral data take a larger share as it accumulates.