Bản lưu ngày / Snapshot of 2026-10-02 · Xem bản mới nhất / See the latest version
WanduraDocsBeta
BetaThese docs are in beta and change often. Something wrong, unclear or missing? Tell us.
Guides

Catalog sync

Markdown
On this page

TL;DR — A job on your backend copies the licensed catalog into your own database every 15–60 minutes. It walks every page, de-duplicates by ID, and changes nothing unless the whole walk succeeded. A title is marked unavailable only after two complete walks without it, and is never deleted. Your app reads films from your database, never from Wandura directly.

Your appYour databaseWandura Delivery APIYour sync jobloop[Every 15 to 60 minutes]GET /catalog?limit=200 (then cursor, until next_cursor is null)films + all their episodes, page by pageOnly after a complete walk - upsert, mark missing rowsHome, film page, episode list

Before you start

How often

Every 15 to 60 minutes, plus a manual trigger for when Wandura announces a release. The catalog sends no change notifications. Never call it on app launch: serve your own copy.

What to store

Table Key Keep Used for
Films films[].id title, slug, synopsis_short, synopsis_full, poster_url, genres, language, audience, form, setting, display_order, planned_episodes, free_until_episode Home, filters, film page, your paywall
Episodes film_id + episode_no (placeholders have no id) id, title, synopsis, duration_ms, poster_url, status, stream_ready Episode list, "coming soon", the ticket request
Both — available yes/no, missed-last-walk yes/no Withdrawals

Keep fields you do not recognise if you like, but never fail on them.

The rules

  1. Walk every page with limit=200, passing next_cursor back unchanged, until it is null. Never stop on a short or empty page.
  2. De-duplicate films by id and episodes by film_id + episode_no; the later copy wins.
  3. All or nothing. If any page fails after retries (retry policy), keep your previous copy untouched and try again next run. A 503 is never an empty catalog.
  4. Withdrawals after two misses. A row missing from two complete walks in a row is marked unavailable: stop showing and selling it. Never delete it: purchases and watch history point at it. If it comes back (a renewal), mark it available again.
  5. Keep Wandura's order: sort by display_order.
  6. Publish gate of your own: show a film only once it has a title, a poster and its first episode stream_ready.

A stale copy never lets anyone watch a withdrawn film: every ticket request checks your licence again.

Code

TypeScript

The walk: every page or an exception, never a partial catalog.

/** Every page, or an exception: never a partial catalog. */
export async function fetchWholeCatalog(o: CallOptions = {}, pageSize = 200): Promise<{ films: CatalogFilm[]; episodes: CatalogEpisode[] }> {
  const films = new Map<string, CatalogFilm>();
  const episodes = new Map<string, CatalogEpisode>();
  let cursor: string | null = null;
  for (let page = 0; page < 10_000; page++) {
    const qs: string = `limit=${pageSize}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
    const { data }: { data: CatalogPage } = await call<CatalogPage>('GET', `/api/delivery/v1/catalog?${qs}`, { policy: BACKGROUND, ...o });
    for (const f of data.films) films.set(f.id, f); // seen twice (catalog changed mid-walk): later copy wins
    for (const e of data.episodes) episodes.set(episodeKey(e), e);
    cursor = data.next_cursor ?? null;
    if (!cursor) return { films: [...films.values()], episodes: [...episodes.values()] };
  }
  throw new Error('catalog walk did not end');
}

Applying a complete walk, with the two-miss rule:

/** One run. Returns false — and changes nothing — when the catalog could not be read completely. */
export async function syncCatalog(store: CatalogStore, o: CallOptions = {}): Promise<boolean> {
  let catalog: { films: CatalogFilm[]; episodes: CatalogEpisode[] };
  try {
    catalog = await fetchWholeCatalog(o);
  } catch (e) {
    // Keep the previous copy; alert if it keeps failing. A failure is never an empty catalog.
    log('catalog_sync_failed', { status: (e as { status?: number }).status, request_id: (e as { requestId?: string }).requestId });
    return false;
  }
  const seen = new Set<string>();
  const films = [...catalog.films].sort((a, b) => a.display_order - b.display_order); // keep Wandura's order
  for (const [i, f] of films.entries()) { await store.upsertFilm(f, i); seen.add(filmKey(f)); }
  for (const e of catalog.episodes) { await store.upsertEpisode(e); seen.add(episodeKey(e)); }
  for (const k of seen) await store.setAvailable(k, true);

  // Withdrawals: unavailable only after two complete walks in a row without the row. Never delete it.
  const before = await store.previousMisses();
  const missing = new Set<string>();
  for (const k of await store.availableKeys()) {
    if (seen.has(k)) continue;
    if (before.has(k)) await store.setAvailable(k, false);
    else missing.add(k);
  }
  await store.savePreviousMisses(missing);
  log('catalog_synced', { films: films.length, episodes: catalog.episodes.length, pending_withdrawals: missing.size });
  return true;
}

call is the client with the canonical retry policy (Retry-After, exponential backoff with full jitter, retry budget):

/** One API call under the retry policy. Resolves with the parsed body; throws WanduraApiError. */
export async function call<T>(method: 'GET' | 'POST', path: string, o: CallOptions = {}): Promise<{ data: T; headers: Headers }> {
  const apiKey = o.apiKey ?? process.env.WANDURA_API_KEY;
  if (!apiKey) throw new Error('WANDURA_API_KEY is not set');
  const p = o.policy ?? BACKGROUND;
  const budget = o.budget ?? sharedBudget;
  const started = Date.now();

  for (let attempt = 0; ; attempt++) {
    budget.noteRequest();
    let res: Response | null = null;
    let body: unknown = null;
    try {
      res = await (o.fetchImpl ?? fetch)(`${BASE_URL}${path}`, {
        method,
        headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', ...(o.body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
        body: o.body !== undefined ? JSON.stringify(o.body) : undefined,
        signal: AbortSignal.timeout(p.timeoutMs),
      });
      body = await res.json().catch(() => null);
    } catch {
      res = null; // network error or timeout
    }
    if (res?.ok) return { data: body as T, headers: res.headers };

    const status = res?.status ?? null;
    const parsed = status === null ? { message: 'network error or timeout' } : parseError(status, body);
    const ra = Number(res?.headers.get('retry-after'));
    const err = new WanduraApiError({ ...parsed, status, retryAfterS: ra > 0 ? ra : null, requestId: parsed.requestId ?? (res ? requestIdOf(res.headers) : undefined) });
    log('wandura_call_failed', { method, path, status, error_code: err.errorCode, reason: err.reason, request_id: err.requestId, attempt, key: keyPrefix(apiKey) });

    const wait = backoffMs(attempt, err.retryAfterS, p);
    const late = Date.now() - started + wait > p.deadlineMs;
    if (!isRetryable(status, err.errorCode) || attempt >= p.maxRetries || late || !budget.tryRetry()) throw err;
    await (o.sleep ?? realSleep)(wait);
  }
}

Python

def fetch_whole_catalog(page_size: int = 200, **call_opts: Any) -> Tuple[List[dict], List[dict]]:
    """Every page, or an exception: never a partial catalog."""
    films: Dict[str, dict] = {}
    episodes: Dict[str, dict] = {}
    cursor = None
    for _ in range(10_000):
        qs = f"limit={page_size}" + (f"&cursor={quote(cursor, safe='')}" if cursor else "")
        data, _headers = call("GET", f"/api/delivery/v1/catalog?{qs}", policy=BACKGROUND, **call_opts)
        for f in data["films"]:
            films[f["id"]] = f  # seen twice (catalog changed mid-walk): the later copy wins
        for e in data["episodes"]:
            episodes[episode_key(e)] = e
        cursor = data.get("next_cursor")
        if not cursor:
            return list(films.values()), list(episodes.values())
    raise RuntimeError("catalog walk did not end")
def sync_catalog(store: MemoryStore, **call_opts: Any) -> bool:
    """One run. Returns False - and changes nothing - when the catalog could not be read completely."""
    try:
        films, episodes = fetch_whole_catalog(**call_opts)
    except WanduraApiError as e:
        # Keep the previous copy; alert if it keeps failing. A failure is never an empty catalog.
        log("catalog_sync_failed", status=e.status, error_code=e.error_code, request_id=e.request_id)
        return False
    seen: Set[str] = set()
    for order, f in enumerate(sorted(films, key=lambda f: f["display_order"])):  # keep Wandura's order
        store.films[film_key(f)] = {**f, "_order": order}
        seen.add(film_key(f))
    for e in episodes:
        store.episodes[episode_key(e)] = e
        seen.add(episode_key(e))
    for k in seen:
        store.available[k] = True
    # Withdrawals: unavailable only after two complete walks in a row without the row. Never delete it.
    missing = set()
    for k, avail in list(store.available.items()):
        if avail and k not in seen:
            if k in store.misses:
                store.available[k] = False
            else:
                missing.add(k)
    store.misses = missing
    log("catalog_synced", films=len(films), episodes=len(episodes), pending_withdrawals=len(missing))
    return True

These files are tested in the docs CI, including a 429 in the middle of a walk and a 503 that must leave your copy untouched.

Done when

  • Your app shows the licensed films from your own database, in Wandura's order.
  • Free episodes, "coming soon" tiles and greyed-out episodes are right.
  • With the job's network blocked for one run, the app's catalog is unchanged.
  • An alert fires when the last successful sync is older than two intervals.

Updated 2 Oct 2026 · Change history (1)

↑↓ selectEnter openEsc close

Send feedback

Your message goes straight to the Wandura team. It includes the address of this page.

0 / 4000