# Đồng bộ open video

> **TL;DR** — Server của bạn quét mọi trang của `GET /videos` bằng key của app, giữ toàn bộ kết quả, và phục vụ link từ đó cho tới `refresh_before`. Chỉ dừng khi `next_cursor === null`, không bao giờ dừng vì một trang ngắn. Retry `429`/`503`; nếu một lượt quét hỏng, tiếp tục phục vụ danh sách trước đó.

## Trước khi bắt đầu
- Mỗi app một key, có `videos:read`, gắn với app đó ([Open video](https://docs.wandura.ai/vi/v1/developers/concepts/open-video#how-it-works)).
- Đồng bộ theo trường nào: `id`, không bao giờ theo `title`.

## Lượt quét
1. Gọi `GET /videos?limit=200`, rồi gọi lại với `cursor=<next_cursor>` (URL-encode, giữ nguyên) cho tới khi `next_cursor` là `null`. Một trang ngắn hay **rỗng** không có nghĩa là đã hết: các video chưa sẵn sàng phát bị bỏ qua.
2. Đặt khoá dòng theo `id`. Lưu cặp `order_code` ↔ `id` một lần nếu bạn ghép với một tệp đơn hàng; không bao giờ ghép theo `title` (tên có thể trùng).
3. Lấy `refresh_before` **sớm nhất** trong mọi trang: các trang trước hết hạn trước.
4. Chỉ thay danh sách app của bạn thấy **khi** lượt quét đã hoàn tất. Nếu lượt quét hỏng, tiếp tục phục vụ danh sách trước đó (link của nó vẫn hợp lệ tới khi chính nó hết hạn) và thử lại sau một phút.
5. Quét lại trước `refresh_before` sớm nhất.

## Code
```ts
/** Every page or an exception (429/503/network are retried inside `call`). Never replace your list with a partial sweep. */
export async function sweep(o: CallOptions = {}): Promise<{ rows: OpenVideo[]; refreshAt: Date }> {
  const rows = new Map<string, OpenVideo>();
  let cursor: string | null = null;
  let refreshAt: Date | null = null;
  for (let page = 0; page < 10_000; page++) {
    const qs: string = `limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
    const { data }: { data: Page } = await call<Page>('GET', `/api/delivery/v1/videos?${qs}`, { policy: BACKGROUND, ...o });
    for (const r of data.rows) rows.set(r.id, r); // key by `id`, never by title
    const rb = new Date(data.refresh_before); // earlier pages expire first: keep the EARLIEST deadline
    if (!refreshAt || rb < refreshAt) refreshAt = rb;
    cursor = data.next_cursor;
    if (cursor === null) return { rows: [...rows.values()], refreshAt }; // a short or empty page is NOT the end
  }
  throw new Error('open video sweep did not end');
}
```

```ts
let current: { rows: OpenVideo[]; refreshAt: Date } | null = null;

/** Keeps `current` fresh. On failure keeps serving the previous list and tries again in a minute. */
export async function refreshLoop(o: CallOptions = {}): Promise<void> {
  let delay = 60_000;
  try {
    current = await sweep(o);
    delay = Math.max(60_000, current.refreshAt.getTime() - Date.now());
  } catch {
    // previous links stay valid until their own expiry
  }
  setTimeout(() => void refreshLoop(o), delay).unref();
}
/** What your app receives: links from your server's cache. Apps never store them. */
export const currentRows = (): OpenVideo[] => current?.rows ?? [];
```

Retry diễn ra bên trong `call`, theo [chính sách retry chuẩn](https://docs.wandura.ai/vi/v1/developers/reference/behaviour#retries). CI của docs chạy code này với một API giả có trang đầu trả `429`, để chứng minh lượt quét không kết thúc với danh sách rỗng.

## Các trường
Mọi trường của một dòng: [Liệt kê open video](https://docs.wandura.ai/vi/v1/developers/reference/videos-list). Những trường hay làm người ta vấp:

- `mp4.p720` có thể là `null`: không có bản 720p. Đừng thay bằng `p480`.
- `order_code` luôn có mặt và có thể là `null`.
- `difficulty` và `kcal_est` hiện luôn là `null`.

## Xong khi
- [ ] Lượt quét có trang đầu trả `429` vẫn kết thúc với đầy đủ danh sách.
- [ ] App lấy link từ server của bạn, không bao giờ lấy từ bộ nhớ lưu trữ.
- [ ] Danh sách được làm mới trước `refresh_before` sớm nhất.

## Liên quan
- [Open video](https://docs.wandura.ai/vi/v1/developers/concepts/open-video)
