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 ("..."). // 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*= 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(); }); }