// Client gọi backend FastAPI. Dev: Vite proxy /api -> :8000. const BASE = ""; async function jpost(path, body) { const r = await fetch(BASE + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error((await r.text()) || r.statusText); return r.json(); } async 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); const r = await fetch(BASE + path, { method: "POST", body: fd }); if (!r.ok) throw new Error((await r.text()) || r.statusText); return r.json(); } // 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); 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) { try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); } } else reject(new Error(xhr.responseText || xhr.statusText || `HTTP ${xhr.status}`)); }; 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) => fetch(BASE + `/api/jobs/${id}`).then((r) => r.json()), listInputs: (sid) => fetch(BASE + `/api/sessions/${sid}/inputs`).then((r) => r.json()), cancel: (id) => jpost(`/api/jobs/${id}/cancel`, {}), dropSession: (sid) => fetch(BASE + `/api/sessions/${sid}`, { method: "DELETE" }).then((r) => r.json()), // 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) => fetch(BASE + `/api/shuffle/segments/${sid}`).then((r) => r.json()), 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) => fetch(BASE + `/api/dub/voices?lang=${lang}`).then((r) => r.json()), 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 }), // 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) => fetch(BASE + `/api/drive/status?username=${encodeURIComponent(username)}`).then((r) => r.json()), driveSetup: (username, folder_link) => jpost("/api/drive/setup", { username, folder_link }), driveRemove: (username) => fetch(BASE + `/api/drive/setup?username=${encodeURIComponent(username)}`, { method: "DELETE" }).then((r) => r.json()), // History getHistory: (username) => fetch(BASE + `/api/history?username=${encodeURIComponent(username)}`).then((r) => r.json()), }; // 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). 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 }); const tick = async () => { if (signal && signal.aborted) return; try { const job = await api.job(id); if (signal && signal.aborted) return; onTick && onTick(job); if (["done", "error", "cancelled"].includes(job.status)) { resolve(job); } else { timer = setTimeout(tick, interval); } } catch (e) { if (!(signal && signal.aborted)) reject(e); } }; tick(); }); }