-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontract.ts
More file actions
188 lines (172 loc) · 9.53 KB
/
Copy pathcontract.ts
File metadata and controls
188 lines (172 loc) · 9.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
// staticine — data contract (NGUỒN CHÂN LÝ / SOURCE OF TRUTH)
//
// VI: Cả PRODUCER (ingest → ghi R2) và CONSUMER (frontend → đọc/phát) import file này.
// Giữ thuần (không I/O, không dep) để vendor/submodule sang frontend dễ. Đổi schema
// phá vỡ = ra /v2 + bump CONTRACT_VERSION; client cũ không vỡ.
// EN: Both PRODUCER (ingest → write R2) and CONSUMER (frontend → read/play) import this.
// Pure (no I/O, no deps) so it vendors cleanly into the frontend. Breaking change =
// ship /v2 + bump CONTRACT_VERSION; old clients keep working.
//
// staticine = static-first movie catalog (chuyên phim) — fork chuyên hoá từ
// nghyane/staticat (CDN-as-database). Khác biệt cốt lõi: BỎ comic, tập trung
// VIDEO + lớp PROXY (Range/CORS/HLS) cho byte nặng ở host ngoài.
export const CONTRACT_VERSION = "v1";
const P = `/${CONTRACT_VERSION}`;
// ── Blob token: data trỏ blob bằng token, đổi host không đụng data ───────────
// VI: R2 giữ DATA + token trỏ blob (video/ảnh/sub). Byte nặng ở host khác.
// EN: R2 holds DATA + a token pointing at the blob (video/image/sub). Heavy bytes live elsewhere.
export type BlobToken = `gd:${string}` | `r2:${string}` | `b2:${string}` | `src:${string}`;
export type BlobHost = "gd" | "r2" | "b2" | "src";
export function parseBlob(token: BlobToken): { host: BlobHost; ref: string } {
const i = token.indexOf(":");
return { host: token.slice(0, i) as BlobHost, ref: token.slice(i + 1) };
}
export const blobToken = (host: BlobHost, ref: string) => `${host}:${ref}` as BlobToken;
// ── Proxy: TÂM ĐIỂM của staticine ────────────────────────────────────────────
// VI: Host nào cần đi qua proxy edge (CORS/Range/raw/giấu origin) trước khi <video> dùng được.
// gd -> Google Drive: cần proxy (CORS-less, confirm-token, quota). MVP only.
// src -> URL nguồn ngoài: thường cần proxy (hotlink/CORS/Range không đảm bảo).
// r2/b2 -> custom domain: phát THẲNG, không cần proxy (đã có CORS + Range chuẩn).
// EN: Which hosts must go through the edge proxy (CORS/Range/raw/hide-origin) before <video> can use them.
export const HOST_NEEDS_PROXY: Record<BlobHost, boolean> = { gd: true, src: true, r2: false, b2: false };
export const needsProxy = (token: BlobToken) => HOST_NEEDS_PROXY[parseBlob(token).host];
/** Cấu hình resolver phía client (inject từ env build-time). */
export interface ResolveConfig {
proxyBase: string; // vd "https://px.example" — endpoint proxy edge
r2Base?: string; // vd "https://cdn.example" — custom domain R2 (phát thẳng)
b2Base?: string; // vd "https://b2.example" — custom domain B2 (phát thẳng)
}
/**
* resolveBlob — token -> URL phát được trong <video src> / <track src>.
* VI: host cần proxy => đi qua /p/<token>; host thẳng => ghép custom domain.
* EN: proxied host => /p/<token>; direct host => join custom domain.
*/
export function resolveBlob(token: BlobToken, cfg: ResolveConfig): string {
const { host, ref } = parseBlob(token);
if (host === "r2" && cfg.r2Base) return `${cfg.r2Base}/${ref}`;
if (host === "b2" && cfg.b2Base) return `${cfg.b2Base}/${ref}`;
if (HOST_NEEDS_PROXY[host]) return paths.proxyAbs(cfg.proxyBase, token);
return ref; // src đã là URL tuyệt đối nhưng không cần proxy (hiếm) — dùng trực tiếp
}
// ── Title / episode ──────────────────────────────────────────────────────────
// VI: work = một PHIM. kind=movie (phim lẻ, ~1 part) | series (phim bộ, N tập).
// EN: work = one TITLE. kind=movie (single film, ~1 part) | series (N episodes).
export type TitleKind = "movie" | "series";
export type Status = "upcoming" | "ongoing" | "completed" | "canceled";
/** title/{id}/head.json — POINTER (mutable duy nhất của title). */
export interface TitleHead {
id: string;
rev: number;
latestAt: number; // epoch s của tập/bản mới nhất
chunks: string[]; // vd ["c0", "c1.v3"] — chunk mới nhất versioned
}
/** title/{id}/meta.v{rev}.json — immutable. Metadata phim. */
export interface TitleMeta {
id: string;
kind: TitleKind;
title: string;
originalTitle?: string | null; // tên gốc (nguyên ngữ)
alt: string[]; // tên khác (search)
cover: BlobToken; // poster dọc
backdrop?: BlobToken | null; // ảnh ngang (hero)
year?: number | null;
runtime?: number | null; // phút (phim lẻ); series = runtime/tập trung bình
status: Status;
genres: string[];
country?: string | null; // mã quốc gia sản xuất (vd "US","KR","VN")
lang: string; // ngôn ngữ thoại gốc
director?: string | null;
cast: string[];
rating?: { tmdb?: number; imdb?: number } | null;
trailer?: BlobToken | null; // src:youtube hoặc blob
desc: string;
}
/** Một dòng trong title/{id}/parts/{chunk}.v{n}.json — newest-first. */
export interface EpisodeRef {
partId: string;
no: string; // "1", "12", "SP1"… (phim lẻ: "1")
label?: string | null; // tên tập tuỳ chọn
date: number; // epoch s phát hành
runtime?: number | null; // phút
}
// ── Part payload (discriminated theo kind) — CHỈ video ──────────────────────
/** Track phụ đề rời (đa ngôn ngữ). */
export interface SubtitleTrack {
lang: string; // BCP-47 vd "vi","en","ja"
label: string; // "Tiếng Việt", "English"
blob: BlobToken; // .vtt/.srt — thường cần proxy nếu ở host ngoài
format: "vtt" | "srt";
default?: boolean;
}
/** Track âm thanh (đa thoại) — chỉ mô tả; nguồn nằm trong HLS/MP4. */
export interface AudioTrack { lang: string; label: string; }
/** part/{partId}.json — phim MP4 progressive (seek bằng HTTP Range qua proxy). */
export interface VideoMp4Part {
partId: string; titleId: string; kind: "video-mp4";
blob: BlobToken; dur: number; w: number; h: number;
subs?: SubtitleTrack[];
audios?: AudioTrack[];
}
/** part/{partId}.json — phim HLS ABR (playlist + segment immutable). */
export interface VideoHlsPart {
partId: string; titleId: string; kind: "video-hls";
playlist: BlobToken; dur: number;
variants: { h: number; bw: number }[];
subs?: SubtitleTrack[];
audios?: AudioTrack[];
}
export type PartIndex = VideoMp4Part | VideoHlsPart;
// ── CatalogEntry — card denormalized cho MỌI listing (feed/genre/status/search)
// VI: mang đủ facet (status, genres, year, rating) để client LỌC tại chỗ, không fetch thêm.
// EN: carries enough facets so the client filters in place — no extra fetch, no server.
export interface CatalogEntry {
id: string;
kind: TitleKind;
title: string;
cover: BlobToken;
year?: number | null;
status: Status;
genres: string[];
rating?: { tmdb?: number; imdb?: number } | null;
alt?: string[]; // tên phụ (search)
latest: { partId: string; no: string; at: number };
}
export interface SearchHead { ver: number; }
// Lọc client-side trên 1 listing đã tải — không server.
export interface CatalogFilter { name?: string; genre?: string; status?: Status; year?: number; kind?: TitleKind; }
export function filterEntries(list: CatalogEntry[], f: CatalogFilter): CatalogEntry[] {
const q = f.name?.trim().toLowerCase();
return list.filter((e) =>
(!f.genre || e.genres.includes(f.genre)) &&
(!f.status || e.status === f.status) &&
(!f.kind || e.kind === f.kind) &&
(f.year == null || e.year === f.year) &&
(!q || e.title.toLowerCase().includes(q) || (e.alt ?? []).some((a) => a.toLowerCase().includes(q)))
);
}
// ── Path builders (1 nguồn chân lý cho URL) ─────────────────────────────────
export const paths = {
titleHead: (id: string) => `${P}/title/${id}/head.json`,
titleMeta: (id: string, rev: number) => `${P}/title/${id}/meta.v${rev}.json`,
partsChunk: (id: string, chunk: string) => `${P}/title/${id}/parts/${chunk}.json`,
part: (partId: string) => `${P}/part/${encodeURIComponent(partId)}.json`,
feedLatest: (page: number) => `${P}/feed/latest/${page}.json`,
feedPopular: (period: "day" | "week" | "all") => `${P}/feed/popular/${period}.json`,
genre: (slug: string, page: number) => `${P}/genre/${slug}/${page}.json`,
status: (status: Status, page: number) => `${P}/status/${status}/${page}.json`,
searchHead: () => `${P}/search/head.json`,
searchIndex: (ver: number) => `${P}/search/index.v${ver}.json`,
// ── Proxy ──
proxy: (token: BlobToken) => `/p/${token}`, // relative
proxyAbs: (base: string, token: BlobToken) => `${base}/p/${token}`, // absolute
} as const;
// ── Cache-Control (producer set lúc PutObject; Cache Rules có thể override) ──
const SWR = "public, max-age=30, stale-while-revalidate=300, stale-if-error=86400";
const IMMUTABLE = "public, max-age=31536000, immutable";
export const cacheControl = {
pointer: SWR, // head.json, feed/latest/0, search/head
popular: "public, max-age=300, stale-while-revalidate=3600",
immutable: IMMUTABLE, // *.v*, part/*, genre/*
// proxy blob (video/sub/segment): immutable byte -> cache cứng từng range
proxyBlob: IMMUTABLE,
} as const;