Bản lưu ngày / Snapshot of 2026-10-12 · Xem bản mới nhất / See the latest version
WanduraDocsBeta
BetaDocs đang ở bản beta và còn thay đổi. Thấy chỗ sai, khó hiểu hoặc còn thiếu? Hãy cho chúng tôi biết.
Hướng dẫn

Luồng playback và paywall

Markdown
Trên trang này

TL;DR — App của bạn gửi backend của bạn lệnh "phát tập N" kèm hai giá trị thiết bị. Backend áp paywall của bạn, rồi gửi Wandura một request: tập đó với "intent": "play" cộng tối đa hai tập kế đã mở khoá dưới dạng prefetch, một viewer_ref đã hash và ổn định, và các giá trị thiết bị. Backend kiểm protection, trả vé về app, và quy mọi lỗi về một trong bốn thông báo cho người xem.

Wandura Delivery APIYour backendYour app (Player SDK)alt[N is locked][N may be watched]ViewerTaps episode NPOST /v1/play (your session, film, N, device values)Paywall for N, N+1, N+2locked - show your paywallPOST /playback: N (play) + unlocked next (prefetch)items[]: a ticket or an error per episodeprotection == private-encrypted?current ticket + prefetched ticketsPlay N, preload N+1, N+2

1. Kiểm paywall của bạn trước

Wandura chỉ kiểm tổ chức của bạn có được stream phim đó hay không; Wandura cấp vé cho bất kỳ tập nào của một phim được cấp phép. Giữ tập bị khoá ở trạng thái khoá là việc của bạn: không bao giờ xin vé cho tập người xem chưa mở khoá, kể cả vé prefetch.

Tập Quyết định
episode_no ≤ free_until_episode Miễn phí
Đã mở khoá qua paywall của bạn (xu, gói thuê bao, VIP…) Được xem
Tập giữ chỗ (id null), stream_ready: false, hoặc đã bị gỡ Không phát được: đừng gọi Wandura
Mọi trường hợp khác Bị khoá: hiện paywall của bạn

2. Một request cho tập đó và các tập kế

  • Phần tử 0: tập người xem vừa chạm, "intent": "play". Luôn gửi nó, kể cả khi app đã bắt đầu tập đó bằng vé prefetch: chính request này mới đếm lượt xem.
  • Phần tử 1–2: các tập tiếp theo người xem được phép xem, "intent": "prefetch". Dừng ở tập bị khoá đầu tiên. Dùng episodes (một danh sách tường minh) thay vì next khi có tập tiếp theo bị khoá.
  • viewer_ref: ổn định theo người xem và thiết bị, đã hash, không chứa dữ liệu cá nhân. Nhờ đó một lần retry nhận lại đúng các vé cũ thay vì vé mới.
  • union_info, drm_kek: giữ nguyên như Player SDK tạo ra.

Thời hạn vé, dùng lại vé và luật theo lô: Hành vi chuẩn của API · Vé playback.

3. Kiểm protection và trả lời app

Chỉ bán tập trả phí khi protection là private-encrypted. Trả vé hiện tại và các vé prefetch về app; vé prefetch nào trả về lỗi thì đơn giản là bỏ ra.

4. Thông báo cho người xem

Quy câu trả lời của Wandura về một trong bốn thông báo (Không khả dụng, Chưa có, Đang bận, Bị lỗi). Bảng đầy đủ theo từng status và mã nằm ở Lỗi; giữ một nhánh mặc định cho các mã bạn chưa biết. Không bao giờ hiện chữ error của Wandura.

5. Bảo vệ endpoint của chính bạn

Endpoint phát của bạn là public với app của bạn. Bắt buộc người xem đã đăng nhập, và giới hạn số vé theo người xem và theo thiết bị: giới hạn của Wandura tính theo tổ chức. Xem Bảo mật.

Code

TypeScript (Node)

viewer_ref:

/** Stable per viewer AND device, meaningless outside your systems (no raw user id, no personal data). */
export function viewerRef(userId: string, installId: string, secret: string): string {
  const h = (msg: string, n: number) => createHmac('sha256', secret).update(msg).digest('base64url').slice(0, n);
  return `${h(`user:${userId}`, 22)}:${h(`device:${installId}`, 11)}`;
}

Quyết định paywall:

/** Free when episode_no <= free_until_episode (null = no free episodes); otherwise your paywall decides. */
export async function mayWatch(d: PlayDeps, userId: string, filmId: string, episodeNo: number): Promise<'ok' | 'locked' | 'not_playable'> {
  const film = await d.film(filmId);
  const ep = await d.episode(filmId, episodeNo);
  // Placeholder rows (id null), episodes not stream_ready and withdrawn rows never reach Wandura.
  if (!film || !ep || !ep.id || !ep.stream_ready || !ep.available) return 'not_playable';
  const free = film.free_until_episode !== null && episodeNo <= film.free_until_episode;
  return free || (await d.hasUnlocked(userId, filmId, episodeNo)) ? 'ok' : 'locked';
}

Endpoint:

export async function handlePlay(r: PlayRequest, d: PlayDeps): Promise<PlayResponse> {
  // 1. Your paywall. A locked episode never reaches Wandura.
  const may = await mayWatch(d, r.userId, r.filmId, r.episodeNo);
  if (may !== 'ok') return { ok: false, outcome: may === 'locked' ? 'locked' : 'not_ready' };

  // 2. Up to two following episodes the viewer may also watch. Stop at the first one that is not.
  const following: number[] = [];
  for (let k = 1; k <= 2 && (await mayWatch(d, r.userId, r.filmId, r.episodeNo + k)) === 'ok'; k++) following.push(r.episodeNo + k);

  // 3. One batch: item 0 is the play, the rest are prefetches. Always send the play — it is what counts the view —
  //    even if the app already started the episode with a prefetched ticket.
  const viewer_ref = viewerRef(r.userId, r.installId, d.viewerRefSecret);
  const body = {
    episodes: [
      { film_ref: r.filmId, episode_no: r.episodeNo, intent: 'play' },
      ...following.map((n) => ({ film_ref: r.filmId, episode_no: n, intent: 'prefetch' })),
    ],
    union_info: r.unionInfo,
    drm_kek: r.drmKek,
    viewer_ref, // stable per viewer + device: a retry gets the SAME ticket back, not a second one
  };
  let items: BatchItem[];
  try {
    items = await requestBatch(body, d.call);
  } catch (e) {
    if (e instanceof WanduraApiError) return { ok: false, outcome: handleTicketError(e).outcome };
    throw e;
  }

  const [anchor, ...rest] = items;
  if (!anchor || anchor.status !== 200) {
    const err = new WanduraApiError({ message: anchor?.error ?? 'no item', status: anchor?.status ?? null, errorCode: anchor?.error_code, reason: anchor?.reason });
    const h = handleTicketError(err);
    log('ticket_refused', { status: err.status, error_code: err.errorCode, reason: err.reason, viewer_ref, alert: h.alert });
    return { ok: false, outcome: h.outcome };
  }
  // 4. Only `private-encrypted` may be sold. Anything else: do not play it, tell Wandura.
  const current = asTicket(anchor);
  if (!current || current.protection !== 'private-encrypted') {
    log('ticket_unexpected_protection', { protection: anchor.protection, viewer_ref });
    return { ok: false, outcome: 'unavailable' };
  }
  // A refused or failed prefetch is not an error: the app asks again when the viewer gets there.
  const next = rest.flatMap((it, i) => {
    const t = it.status === 200 ? asTicket(it) : null;
    const n = following[i];
    return t && n !== undefined && t.protection === 'private-encrypted' ? [{ episode_no: n, ticket: t }] : [];
  });
  return { ok: true, current, next };
}

/** POST /playback. If only item 0 failed with a retryable status, retry once after its own `retry_after`. */
async function requestBatch(body: object, o: CallOptions = {}): Promise<BatchItem[]> {
  for (let attempt = 0; ; attempt++) {
    const { data } = await call<{ items: BatchItem[] }>('POST', '/api/delivery/v1/playback', { policy: INTERACTIVE, ...o, body });
    const a = data.items[0];
    if (!a || a.status === 200 || attempt >= 1 || !isRetryable(a.status, a.error_code)) return data.items;
    const wait = (a.retry_after ?? 1) * 1_000 + Math.random() * 1_000;
    await (o.sleep ?? ((ms: number) => new Promise<void>((res) => setTimeout(res, ms))))(wait);
  }
}

function asTicket(it: BatchItem): Ticket | null {
  if (!it.vid || !it.play_auth_token || !it.expires_at || !it.protection) return null;
  return { vid: it.vid, play_auth_token: it.play_auth_token, expires_at: it.expires_at, protection: it.protection, subtitles: it.subtitles ?? [] };
}

Lỗi → kết quả cho app của bạn:

/** YOUR codes for your app. Never pass Wandura's `error` text to viewers. */
export type AppOutcome =
  | 'unavailable' // licence not active — "This title is no longer available"
  | 'not_ready' // not playable yet — "Not available yet"
  | 'busy' // retried already — "Busy, try again in a moment"
  | 'broken'; // a bug on your side — "Couldn't load this episode"

/** `alert` = page your engineers. `confirm: 'catalog'` = re-check with the catalog sync before an irreversible step. */
export type Handling = { outcome: AppOutcome; alert: boolean; confirm?: 'catalog' };

export function handleTicketError(e: WanduraApiError): Handling {
  switch (e.errorCode) {
    case 'not_entitled': return { outcome: 'unavailable', alert: false, confirm: 'catalog' };
    case 'not_ready': return { outcome: 'not_ready', alert: false };
    case 'rate_limited':
    case 'service_unavailable': return { outcome: 'busy', alert: false };
    case 'sdk_required':
    case 'invalid_device_info':
    case 'invalid_request':
    case 'unsupported_version': return { outcome: 'broken', alert: true };
  }
  // Unknown or missing `error_code`: decide by status class.
  const s = e.status;
  if (s === null || s === 429 || s >= 500) return { outcome: 'busy', alert: s !== null && s >= 500 };
  return { outcome: 'broken', alert: true };
}

Python

def handle_play(user_id: str, install_id: str, film: dict, episodes_by_no: Dict[int, dict], episode_no: int,
                union_info: str, drm_kek: str, unlocked: Callable[[int], bool], secret: bytes, **call_opts: Any) -> Dict[str, Any]:
    decision = may_watch(film, episodes_by_no.get(episode_no), lambda: unlocked(episode_no))
    if decision != "ok":
        return {"ok": False, "outcome": "locked" if decision == "locked" else "not_ready"}
    following: List[int] = []
    for n in (episode_no + 1, episode_no + 2):
        if may_watch(film, episodes_by_no.get(n), lambda n=n: unlocked(n)) != "ok":
            break
        following.append(n)

    ref = viewer_ref(user_id, install_id, secret)
    body = {  # item 0 = the play (counts the view), the rest = prefetches
        "episodes": [{"film_ref": film["id"], "episode_no": episode_no, "intent": "play"}]
        + [{"film_ref": film["id"], "episode_no": n, "intent": "prefetch"} for n in following],
        "union_info": union_info,
        "drm_kek": drm_kek,
        "viewer_ref": ref,  # stable per viewer + device: a retry gets the same ticket back
    }
    try:
        data, _ = call("POST", "/api/delivery/v1/playback", body=body, policy=INTERACTIVE, **call_opts)
    except WanduraApiError as e:
        return {"ok": False, **handle_ticket_error(e.status, e.error_code)}

    items = data.get("items") or []
    anchor = items[0] if items else {}
    if anchor.get("status") != 200:
        log("ticket_refused", status=anchor.get("status"), error_code=anchor.get("error_code"), reason=anchor.get("reason"), viewer_ref=ref)
        return {"ok": False, **handle_ticket_error(anchor.get("status"), anchor.get("error_code"))}
    if anchor.get("protection") != "private-encrypted":  # only private-encrypted may be sold
        log("ticket_unexpected_protection", protection=anchor.get("protection"), viewer_ref=ref)
        return {"ok": False, "outcome": "unavailable", "alert": True}
    keep = ("vid", "play_auth_token", "expires_at", "subtitles")
    nxt = [{"episode_no": n, "ticket": {k: it.get(k) for k in keep}}
           for n, it in zip(following, items[1:])
           if it.get("status") == 200 and it.get("protection") == "private-encrypted"]  # refused prefetch = not an error
    return {"ok": True, "current": {k: anchor.get(k) for k in keep}, "next": nxt}
_OUTCOMES = {
    "not_entitled": ("unavailable", False),  # confirm with the catalog sync before you stop selling
    "not_ready": ("not_ready", False),
    "rate_limited": ("busy", False),
    "service_unavailable": ("busy", False),
    "sdk_required": ("broken", True),
    "invalid_device_info": ("broken", True),
    "invalid_request": ("broken", True),
    "unsupported_version": ("broken", True),
}


def handle_ticket_error(status: Optional[int], error_code: Optional[str]) -> Dict[str, Any]:
    """Your app's outcome for a failed ticket. Never show Wandura's `error` text to viewers."""
    if error_code in _OUTCOMES:
        outcome, alert = _OUTCOMES[error_code]
    elif status is None or status == 429 or status >= 500:  # unknown code: decide by status class
        outcome, alert = "busy", status is not None and status >= 500
    else:
        outcome, alert = "broken", True
    return {"outcome": outcome, "alert": alert}

Phía app của cùng luồng này, bằng Swift và Kotlin: Player SDK trên iOS, trên Android.

Xong khi

  • Tập bị khoá hiện paywall của bạn, và log của bạn không có request Wandura nào cho tập đó.
  • Bắt đầu một tập gửi một request: tập đó (play) cộng các tập kế đã mở khoá (prefetch).
  • viewer_ref ổn định theo người xem và thiết bị, và không chứa dữ liệu cá nhân.
  • Mọi câu trả lời trong Lỗi đều quy về một màn hình, có nhánh mặc định.
  • Vé có protection khác private-encrypted không bao giờ được phát.

Cập nhật 02/10/2026 · Lịch sử thay đổi (1)

↑↓ chọnEnter mởEsc đóng

Gửi góp ý

Góp ý được gửi thẳng tới đội Wandura, kèm địa chỉ trang này.

0 / 4000