Skip to content
Implementation guide

How to Build Mood-Based Discovery Into a Streaming Service

The working parts of a mood-based tab, in the order you build them, with JavaScript we ran against the live API on 5 November 2026.

To build mood-based discovery, write themes as preference strings, preselect up to ten candidates per theme from your catalog, and rank them with the TasteRay API. Cache rows ahead of time because responses take seconds. Store rejections yourself, filter them out, send them as history, and test everything in a separate tab.

You need a server, a catalog and an API key, which takes one email. The API has no theme endpoint, no memory and no TV SDK, so those parts are yours, and most of this guide is about them. Examples use JavaScript on Node or Bun, and the fields come from the API reference.

The build, in order

Each step needs the one before it, and the sections below take them one at a time.

  1. Write your themes

    List the moods, company and time slots you will offer, each as preference strings and a catalog filter.

  2. Preselect candidates

    Cut your catalog to at most ten titles per theme, using your rights data and your own scores.

  3. Rank with the API

    Send the candidates and the viewer context to POST /v1/recommend, then match the results back to your catalog by item.id.

  4. Cache and precompute

    Fill rows ahead of time by theme and audience segment, and serve them from your own store.

  5. Remember rejections

    Store what a viewer rejects, remove it from candidates, and send it in the history field.

  6. Test it in a separate tab

    Ship to a random share of viewers in a tab of its own, and compare against a control group.

1. Write your themes

A theme is your copy plus a filter. "Scare Me" means nothing to the API by itself, so spell out what it means for your catalog in preferences, and put hard limits in constraints. Docs suggest three to seven preferences: past that, each one dilutes the rest.

If you want AI-written themes, generate them with your own model and put an editor in front of the output. Clips are also yours, from your assets.

js
export const THEMES = {
  'scare-me': {
    label: 'Scare Me',
    vertical: 'movies',
    preferences: [
      'Theme: Scare Me',
      'genuinely frightening',
      'atmospheric dread over jump scares',
    ],
    where: (title) => title.genres.includes('horror'),
  },
  'quick-watches': {
    label: 'Quick Watches',
    vertical: 'tv-series',
    preferences: ['Theme: Quick Watches'],
    constraints: { max_runtime_minutes: 30 },
    where: (title) => title.runtimeMinutes <= 30,
  },
};
themes.js. The where function runs on your side, before the API sees anything.

2. Preselect candidates

options.count caps at 10, so the docs tell you to rank in batches of ten. Your filter decides what the model ever sees, because the API only returns items from the list you send. Rights, territory and content rating belong here.

For "Something Different", call it again with the next offset and exclude what the viewer has already seen.

js
export function preselect(catalog, theme, viewer, offset = 0) {
  return catalog
    .filter((t) => t.territories.includes(viewer.territory))
    .filter((t) => !viewer.rejectedIds.has(t.id))
    .filter(theme.where)
    .sort((a, b) => b.yourScore - a.yourScore)
    .slice(offset, offset + 10)
    .map((t) => ({
      id: t.id,
      name: t.name, // keep names unique: see matchToCatalog
      description: t.description,
      metadata: { year: t.year, runtime_minutes: t.runtimeMinutes },
    }));
}
preselect.js. yourScore is whatever you already rank by: popularity, margin, editorial weight.

3. Rank with the API

Each returned item carries the id and the exact name you sent, so you match on item.id. In an early capture, before the API returned your own fields, we sent "Brian Eno — Music for Airports" and got back "Music for Airports — Brian Eno"; that no longer happens. Results not in your list are dropped, so fewer than count can come back. The code below logs and drops anything it cannot match, which is the safe failure: your row is shorter and nothing wrong reaches a viewer.

Set a long client timeout. Show runtime, cast and availability from your own catalog, and never from item.metadata, which the model fills in by itself: when we ranked a list without sending directors, it named the wrong director of Aftersun.

js
const ENDPOINT = 'https://api.tasteray.com/v1/recommend';

export async function rank(theme, items, viewer, count = 3) {
  if (items.length === 0) return []; // count must be 1 to 10
  const res = await fetch(ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.TASTERAY_API_KEY,
    },
    signal: AbortSignal.timeout(60_000),
    body: JSON.stringify({
      vertical: theme.vertical,
      items,
      context: {
        preferences: theme.preferences,
        constraints: theme.constraints,
        profile: viewer.profile,
        history: viewer.rejections.slice(-50),
      },
      options: {
        count: Math.min(count, items.length),
        fast: true,
        explanation_depth: 'brief',
        language: viewer.locale,
      },
    }),
  });
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(`${res.status} ${error.code}: ${error.message}`);
  }
  const { recommendations, meta } = await res.json();
  return matchToCatalog(recommendations, items, meta.request_id);
}

// Each returned item carries the id you sent. Match on it, and
// drop what does not match. Fewer than count can come back.
function matchToCatalog(recommendations, items, requestId) {
  const byId = new Map(items.map((i) => [i.id, i]));
  return recommendations.flatMap((r) => {
    const item = byId.get(r.item.id);
    if (!item) {
      console.warn('unmatched', requestId, r.item.id);
      return [];
    }
    return [{
      id: item.id,
      // taste_match_score is 0-100, confidence 0.0-1.0
      score: r.metadata?.taste_match_score ?? r.confidence * 100,
      why: r.explanation.why_match,
      factors: r.explanation.key_factors ?? [],
      requestId,
    }];
  });
}
rank.js. Retries for 429 and 5xx are in the API reference under "Retry failed requests".
Request body
{
  "vertical": "tv-series",
  "context": {
    "preferences": ["Theme: Quick Watches"],
    "constraints": {"max_runtime_minutes": 30}
  },
  "items": [
    {"id": "q1", "name": "Fleabag, S1E1", "description": "A dry, confessional comedy about a woman in London. Episode runs about 24 minutes.", "metadata": {"kind": "episode", "runtime_minutes": 24}},
    {"id": "q2", "name": "Schitt's Creek, S1E1", "description": "A rich family loses everything and moves to a small town. About 22 minutes.", "metadata": {"kind": "episode", "runtime_minutes": 22}},
    // ...and 8 more titles: Oppenheimer, The Irishman and others
  ],
  "options": {"count": 3}
}
Real capture, , from the keyless demo in fast mode, which returns at most three results. Two of the three results, with one of the four key_factors each. Round trip 2.4 s, meta.processing_time_ms 1,955. The model's sentences are verbatim.
  1. Love, Death & Robots: Zima Blue

    98 out of 100, taste_match_score

    You want a quick watch that leaves an impact without demanding a full evening. Zima Blue is a 10-minute masterpiece that delivers a complete, emotionally resonant story with a jaw-dropping twist, perfect for when you have exactly 10 minutes to spare.

    • The storytelling is tight and effective; every second counts, making it a perfect fit for the 'Quick Watches' theme.
  2. Schitt's Creek, S1E1

    95 out of 100, taste_match_score

    If you’re looking for comfort and heart in a quick format, this is it. A 22-minute warm, funny, and immediately engaging episode that fits perfectly within your time limit while offering a strong start to a beloved series.

    • At 22 minutes, it respects your 30-minute constraint while providing a full episode experience with setup and payoff.

Fields shown: item.name, metadata.taste_match_score, explanation.why_match, explanation.key_factors.

4. Cache and precompute

Responses are generated fresh on every call. In our 13 fast-mode captures on 5 November 2026, eleven took between 2.0 and 3.0 seconds and two took 20.8 and 27.9 seconds. A later test of this code took 17.1 seconds. Nothing on a TV screen should wait for that, so viewers see rows you built earlier.

Precompute by theme, territory, locale and audience segment, off the request path. Keep a live request for the moment a viewer types something, with a skeleton on screen. Each response reports meta.processing_time_ms, so log it and watch your own numbers. The six-hour lifetime below is a starting point to tune.

js
const SIX_HOURS = 6 * 60 * 60;

export async function themeRow(cache, catalog, themeId, viewer) {
  const key = `row:${themeId}:${viewer.territory}:${viewer.locale}`
    + `:${viewer.segment}:${viewer.rejectionsVersion}`;
  const hit = await cache.get(key);
  if (hit) return hit;
  const theme = THEMES[themeId];
  const row = await rank(theme, preselect(catalog, theme, viewer), viewer);
  await cache.set(key, row, SIX_HOURS);
  return row;
}

// Off the request path, one at a time, inside your rate limit.
export async function precompute(cache, catalog, audiences) {
  for (const viewer of audiences) {
    for (const themeId of Object.keys(THEMES)) {
      await themeRow(cache, catalog, themeId, viewer);
    }
  }
}
cache.js. Store is yours: Redis, KV or a table, anything with get and set.

5. Remember rejections

The API is stateless, so "do not show me this again" is a table in your database. Use it twice: remove the title from candidates, which is a guarantee, and send it in context.history, which lets the model reason about what the viewer turned down. History takes up to 50 items, each with item and rating strings.

The capture below sent the first ten-title list again with two titles rejected, and neither came back. Read what the model writes before you show it. One key factor in this response said: "You rejected *Past Lives* and *Lost in Translation* because they ask for emotional labor. Paterson asks for nothing. It just lets you be. It’s the perfect Sunday night reset." The viewer never gave a reason, so the model invented one. Consider hiding factors that mention a rejection.

js
export function withRejections(viewer, rejectedTitles) {
  return {
    ...viewer,
    rejectedIds: new Set(rejectedTitles.map((t) => t.id)),
    rejections: rejectedTitles.map((t) => ({
      item: t.name,
      rating: 'rejected: do not suggest again',
    })),
    rejectionsVersion: rejectedTitles.length,
  };
}
rejections.js. Bumping rejectionsVersion gives this viewer a new cache key.
Request body
{
  "vertical": "movies",
  "context": {
    "profile": "Niche SVOD subscriber. Sunday night, wrung out after a long week, wants something gentle and good. Nothing dumb.",
    "preferences": ["quiet", "humane", "well made"],
    "history": [{"item": "Past Lives", "rating": "rejected: do not suggest again"}, {"item": "Lost in Translation", "rating": "rejected: do not suggest again"}]
  },
  "items": [
    {"id": "m1", "name": "Paterson", "description": "A bus driver in New Jersey writes poems in a notebook through an ordinary, gentle week.", "metadata": {"year": 2016, "runtime_minutes": 118}},
    {"id": "m2", "name": "Lost in Translation", "description": "Two lonely strangers in Tokyo form a quiet bond over a few sleepless nights.", "metadata": {"year": 2003, "runtime_minutes": 102}},
    // ...and 8 more titles
  ],
  "options": {"count": 3}
}
Real capture, , from the keyless demo in fast mode, which returns at most three results. Two of the three results, with one of the four key_factors each. Round trip 2.5 s, meta.processing_time_ms 2,099. The model's sentences are verbatim.
  1. Paterson

    95 out of 100, taste_match_score

    After a long, wrung-out week, you don’t need another plot twist; you need permission to just exist. Paterson is a gentle, meditative embrace that respects your intelligence without asking you to be anywhere but right where you are.

    • At 118 minutes, it’s substantial enough to feel like a real experience, but the pacing is so relaxed that it never feels like a chore. It’s the cinematic equivalent of a warm bath at 9 PM.
  2. Columbus

    88 out of 100, taste_match_score

    You’re a niche subscriber who likes 'well made.' Columbus is an architectural dream that happens to be a movie. It’s quiet, thoughtful, and visually stunning—a perfect way to wind down without feeling drained.

    • You want 'humane.' A stranger and a young woman finding a brief, tender connection in a small Indiana town is a beautifully human story. It’s not grand, but it’s real.

Fields shown: item.name, metadata.taste_match_score, explanation.why_match, explanation.key_factors.

6. Test it in a separate tab

Keep it out of your home screen until it has earned a place there. Netflix took the same route: its Help Center says AI Discover is optional and "your regular Netflix experience stays the same", and its post calls the tab a "test kitchen". A separate tab means a bad week costs you a tab.

Assign viewers by a stable hash, and store meta.request_id with every row you log, so you can trace a bad result. Measure what you already care about, in both arms: plays that start from the tab, time from opening to play, completion, how often viewers come back to the tab, and how often they reject a pick.

js
import { createHash } from 'node:crypto';

export function arm(viewerId, experiment = 'discover-tab-v1') {
  const digest = createHash('sha256')
    .update(`${experiment}:${viewerId}`)
    .digest();
  return digest[0] % 2 === 0 ? 'discover-tab' : 'control';
}
experiment.js. The same viewer always lands in the same arm.

TasteRay is not affiliated with Netflix. This guide does not describe how Netflix built AI Discover, which Netflix has not disclosed. Questions about a larger rollout: hello@tasteray.com.

Sources and further reading

FAQs about How to Build Mood-Based Discovery Into a Streaming Service

Does the TasteRay API generate discovery themes?

No, the TasteRay API has no theme endpoint. You write each theme as preference strings and a catalog filter, then send it as a normal recommendation request. Teams that want AI-written themes generate them with their own model and put an editor in front of the output before viewers see it.

How many titles can I rank in one TasteRay API request?

The options.count parameter accepts 1 to 10, so the documentation advises ranking in batches of ten. Preselect candidates with your own rights data and scores before calling the API. To go deeper into a theme, call it again with the next batch, leaving out whatever the viewer has already seen.

How do I match API results back to my catalog?

Match on `item.id`: the response returns the `id` and exact `name` you sent. Items need a non-empty string `id` and `name`, or the request gets a 400. Log and drop anything that fails to match, since a shorter row is better than showing a viewer the wrong title. Fewer results than `count` can come back, because anything outside your list is dropped.

How should I handle latency in a mood-based tab?

Precompute rows by theme, territory, locale and audience segment, then serve them from your own cache. Most fast-mode responses in our 5 November 2026 tests took two to three seconds, with outliers above 20. Keep live calls for free-text requests, show a skeleton while they run, and set a generous client timeout.