Administrator
Claude Opus 5
fix(infra): chặn 502 từ gốc — uvicorn đóng kết nối rảnh quá sớm
a04fadb
Raw History Blame Contribute Delete
13.5 kB
import { currentCtx as trackCtx } from "./lib/track";
// Client gọi backend FastAPI. Dev: Vite proxy /api -> :8000.
const BASE = "";
// Máy chủ KHÔNG phải lúc nào cũng trả JSON: Space bận, vừa khởi động lại hoặc
// cổng vào HF hết giờ chờ thì nó chen vào một trang HTML ("<!DOCTYPE html>...").
// Gọi thẳng r.json() lúc đó ném "Unexpected token '<'" — câu người dùng không
// hiểu, lại che mất chuyện đang thật sự xảy ra. Mọi lời gọi đi qua đây: đọc body
// MỘT lần, tự nhận ra HTML/không-JSON, và gắn cờ `transient` cho những lỗi chỉ
// là hụt hơi tạm thời (mạng chớp, 5xx, 429, 408) để chỗ gọi biết nên thử lại.
const BUSY_MSG = "Máy chủ đang bận hoặc vừa khởi động lại, chưa trả về dữ liệu.";
function tagged(msg, { status = 0, transient = false } = {}) {
const e = new Error(msg);
e.status = status;
e.transient = transient;
return e;
}
// Lỗi FastAPI trả về dạng {"detail": "..."} — lấy ra câu chữ cho người đọc,
// đừng ném nguyên cục JSON (hay nguyên trang HTML) vào alert.
function httpError(r, text, data) {
const d = data?.detail ?? data?.error ?? data?.message;
let msg = "";
if (typeof d === "string" && d) msg = d;
else if (Array.isArray(d) && d.length) msg = d.map((x) => x.msg || "").join("; ");
else if (text && !/^\s*</.test(text) && text.trim().length <= 300) msg = text.trim();
else msg = `${BUSY_MSG} (lỗi ${r.status}${r.statusText ? " " + r.statusText : ""})`;
return tagged(msg, {
status: r.status,
transient: r.status >= 500 || r.status === 429 || r.status === 408,
});
}
async function readJson(r) {
const text = await r.text().catch(() => "");
let data = null, isJson = false;
if (text) { try { data = JSON.parse(text); isJson = true; } catch { /* không phải JSON */ } }
if (!r.ok) throw httpError(r, text, data);
if (!isJson) throw tagged(`${BUSY_MSG} Thử lại sau giây lát.`, { status: r.status, transient: true });
return data;
}
const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
// Mở ra cho các trang gọi thẳng (Listing, Flux2, Đăng nhập, Quản trị...) —
// đừng dùng fetch trần rồi r.json() nữa, lỗi "Unexpected token '<'" sinh ra từ đó.
//
// Lời gọi CHỈ ĐỌC (GET/HEAD) gặp trục trặc tạm thời thì tự thử lại vài nhịp:
// đọc lại thì cùng lắm tốn thêm một vòng, không đẻ ra việc mới. Lệnh GHI
// (POST/DELETE) KHÔNG tự thử lại — bấm một lần mà chạy hai job thì tai hại hơn
// là báo lỗi để người dùng tự bấm lại.
export async function request(path, init, { retries = 2, backoff = 600 } = {}) {
const method = (init?.method || "GET").toUpperCase();
const canRetry = method === "GET" || method === "HEAD";
for (let attempt = 0; ; attempt++) {
try {
let r;
try {
r = await fetch(BASE + path, init);
} catch (e) {
// Huỷ có chủ đích (AbortController hết giờ chờ) không phải trục trặc
// mạng — trả nguyên lỗi để chỗ gọi tự phân biệt.
if (e?.name === "AbortError") throw e;
// fetch chỉ ném khi mạng đứt / máy chủ chưa nghe — luôn là tạm thời.
throw tagged("Mất kết nối tới máy chủ. Kiểm tra mạng rồi thử lại.", { transient: true });
}
return await readJson(r);
} catch (e) {
if (!canRetry || !e?.transient || attempt >= retries || init?.signal?.aborted) throw e;
await sleep(backoff * (attempt + 1));
}
}
}
export const jget = (path) => request(path);
export function jpost(path, body) {
return request(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function upload(path, file, fields = {}) {
const fd = new FormData();
fd.append("file", file);
for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v);
return request(path, { method: "POST", body: fd });
}
// Upload 1 file kèm tiến độ BYTE (fetch không có progress event -> dùng XHR).
// onProgress(loaded, total) gọi liên tục khi bytes đẩy lên.
export function uploadWithProgress(path, file, fields = {}, onProgress) {
return new Promise((resolve, reject) => {
const fd = new FormData();
fd.append("file", file);
for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v);
const xhr = new XMLHttpRequest();
xhr.open("POST", BASE + path);
// Upload đi bằng XHR (để có % tiến độ) nên KHÔNG qua bản vá fetch trong
// lib/track.js -> gắn tay ngữ cảnh theo dõi để backend vẫn quy được về
// đúng task của đúng người.
try {
const c = trackCtx();
if (c.user_id) {
xhr.setRequestHeader("X-TNT-User", c.user_id);
xhr.setRequestHeader("X-TNT-Session", c.session_id);
if (c.task_id) xhr.setRequestHeader("X-TNT-Task", c.task_id);
if (c.feature) xhr.setRequestHeader("X-TNT-Feature", c.feature);
}
} catch { /* thiếu header theo dõi không được chặn upload */ }
if (xhr.upload && onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded, e.total);
};
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
// 200 mà thân là HTML (cổng vào chen vào) thì coi như hỏng tạm thời,
// đừng resolve {} rồi để chỗ gọi vấp vào chỗ thiếu trường.
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(tagged(`${BUSY_MSG} Thử lại sau giây lát.`, { status: xhr.status, transient: true })); }
} else {
// Non-2xx cũng có thể là trang HTML của cổng vào, không phải JSON.
let msg = "";
try {
const j = JSON.parse(xhr.responseText);
const d = j?.detail ?? j?.error ?? j?.message;
if (typeof d === "string") msg = d;
} catch { /* không phải JSON */ }
if (!msg) {
const t = (xhr.responseText || "").trim();
msg = t && !t.startsWith("<") && t.length <= 300
? t
: `${BUSY_MSG} (lỗi ${xhr.status}${xhr.statusText ? " " + xhr.statusText : ""})`;
}
reject(tagged(msg, { status: xhr.status, transient: xhr.status >= 500 }));
}
};
xhr.onerror = () => reject(new Error("Lỗi mạng khi tải lên"));
xhr.onabort = () => reject(new Error("Đã huỷ tải lên"));
xhr.send(fd);
});
}
// Chạy nhiều tác vụ song song có GIỚI HẠN luồng (mặc định 4) — nhanh hơn tuần tự
// mà không làm nghẽn trình duyệt (mỗi host chỉ ~6 kết nối đồng thời).
export async function runPool(count, worker, limit = 4) {
let next = 0;
const run = async () => {
while (next < count) {
const i = next++;
await worker(i);
}
};
await Promise.all(Array.from({ length: Math.min(limit, count) }, run));
}
export const fileUrl = (u) => BASE + u;
export const api = {
job: (id) => jget(`/api/jobs/${id}`),
listInputs: (sid) => jget(`/api/sessions/${sid}/inputs`),
cancel: (id) => jpost(`/api/jobs/${id}/cancel`, {}),
dropSession: (sid) => request(`/api/sessions/${sid}`, { method: "DELETE" }),
// Down list video (không dùng cookie)
parseLinks: (raw) => jpost("/api/download/parse", { raw }),
startDownload: (urls, zip_output, username = "") =>
jpost("/api/download/start", { urls, zip_output, username }),
push: (session, paths, target_tool) =>
jpost("/api/download/push", { session, paths, target_tool }),
// Cut video
cutUpload: (file, session) => upload("/api/cut/upload", file, { session }),
cutPreview: (req) => jpost("/api/cut/preview", req),
cutExecute: (req) => jpost("/api/cut/execute", req),
cutExecuteBatch: (req) => jpost("/api/cut/execute-batch", req),
// segments = mảng nhóm: segments[i] = các khúc thứ i của mọi video -> cùng 1 vị trí Xào
cutPushShuffle: (session, segments) =>
jpost("/api/cut/push-shuffle", { session, segments }),
// Tách video -> video không lời + mp3
cutSplitAudio: (session, file, username = "") =>
jpost("/api/cut/split-audio", { session, file, username }),
// Xào video
shuffleUpload: (file, segment, session) =>
upload("/api/shuffle/upload", file, { segment, session }),
shuffleUploadAudio: (file, session) =>
upload("/api/shuffle/upload-audio", file, { session }),
// Bản có tiến độ byte (dùng cho loader nhanh + chuẩn khi thả clip/thư mục).
shuffleUploadP: (file, segment, session, onProgress) =>
uploadWithProgress("/api/shuffle/upload", file, { segment, session }, onProgress),
shuffleUploadAudioP: (file, session, onProgress) =>
uploadWithProgress("/api/shuffle/upload-audio", file, { session }, onProgress),
shuffleSegments: (sid) => jget(`/api/shuffle/segments/${sid}`),
shuffleEstimate: (req) => jpost("/api/shuffle/estimate", req),
shufflePlan: (req) => jpost("/api/shuffle/plan", req),
shuffleGenerate: (req) => jpost("/api/shuffle/generate", req),
// Dịch / đổi giọng
dubUpload: (file, session) => upload("/api/dub/upload", file, { session }),
dubVoiceSample: (file, session) =>
upload("/api/dub/voice-sample", file, { session }),
dubVoices: (lang) => jget(`/api/dub/voices?lang=${lang}`),
dubStart: (req) => jpost("/api/dub/start", req),
// 2 pha: dịch trước (xem/sửa) rồi mới tạo giọng
dubPrepare: (req) => jpost("/api/dub/prepare", req),
dubSynthesize: (req) => jpost("/api/dub/synthesize", req),
dubTts: (req) => jpost("/api/dub/tts", req),
// Nạp SRT tự tải (thay bản dịch tự động) — trả lines đã bỏ nhãn [tên]
dubSrtImport: (file, session) => upload("/api/dub/subtitle/import", file, { session }),
// Nhận dạng lời gốc trong video (ASR, không dịch) — cho tab "Chèn chữ vào video"
dubTranscribe: (req) => jpost("/api/dub/subtitle/transcribe", req),
// Hard subtitle
subtitlePlan: (session, srt) => jpost("/api/dub/subtitle/plan", { session, srt }),
subtitleBurn: (req) => jpost("/api/dub/subtitle", req),
// Xem trước KHỚP 100% bản burn: server render đúng 1 khung tại `time`.
subtitlePreviewFrame: (session, video, segments, mode, time) =>
jpost("/api/dub/subtitle/preview-frame", { session, video, segments, mode, time }),
// Drive
driveStatus: (username) => jget(`/api/drive/status?username=${encodeURIComponent(username)}`),
driveSetup: (username, folder_link) => jpost("/api/drive/setup", { username, folder_link }),
driveRemove: (username) => request(`/api/drive/setup?username=${encodeURIComponent(username)}`, { method: "DELETE" }),
// History
getHistory: (username) => jget(`/api/history?username=${encodeURIComponent(username)}`),
};
// Poll một job tới khi xong. onTick(job) gọi mỗi lần cập nhật.
// `signal` (AbortSignal, tuỳ chọn): khi component unmount/huỷ, truyền signal đã
// abort để DỪNG vòng poll — tránh rò rỉ setTimeout và setState trên component đã
// gỡ. Khi bị abort, promise KHÔNG resolve (để code đang await không chạy tiếp).
// Chịu đựng máy chủ mất tiếng tối đa chừng này rồi mới báo hỏng.
const POLL_GRACE_MS = 90_000;
export function pollJob(id, onTick, interval = 1000, signal) {
return new Promise((resolve, reject) => {
let timer = null;
const stop = () => { if (timer) clearTimeout(timer); };
if (signal) signal.addEventListener("abort", stop, { once: true });
// Job chạy TRÊN MÁY CHỦ, vòng này chỉ đứng ngoài hỏi thăm. Một cú 502 của
// cổng vào Space hay mạng chớp giữa chừng KHÔNG có nghĩa là job hỏng — nên
// hỏi lại, chỉ bỏ cuộc khi mất liên lạc quá lâu. (Trước đây lỗi đầu tiên là
// văng luôn: người dùng mất việc đang chạy dở ở 60%.)
let failedSince = 0;
const tick = async () => {
if (signal && signal.aborted) return;
try {
const job = await api.job(id);
if (signal && signal.aborted) return;
failedSince = 0;
onTick && onTick(job);
if (["done", "error", "cancelled"].includes(job.status)) {
resolve(job);
} else {
timer = setTimeout(tick, interval);
}
} catch (e) {
if (signal && signal.aborted) return;
// Máy chủ trả lời rành mạch "không có job này" -> nó vừa khởi động lại,
// tiến trình trong RAM mất rồi, hỏi thêm cũng vô ích.
if (e?.status === 404) {
return reject(new Error("Máy chủ vừa khởi động lại nên không còn tiến trình này. Hãy chạy lại."));
}
if (!e?.transient) return reject(e);
const now = Date.now();
if (!failedSince) failedSince = now;
if (now - failedSince > POLL_GRACE_MS) {
return reject(new Error(`${e.message} (mất liên lạc với máy chủ quá lâu)`));
}
// Máy chủ đang hụt hơi -> giãn nhịp hỏi ra, đừng dồn thêm.
timer = setTimeout(tick, Math.min(5000, Math.max(interval, interval * 2)));
}
};
tick();
});
}