File size: 13,515 Bytes
00d2c12
9b53f70
 
 
 
5b27e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07d3e14
5b27e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07d3e14
5b27e49
 
 
 
 
 
 
a04fadb
 
d94104e
 
a04fadb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b27e49
07d3e14
 
d94104e
5b27e49
d94104e
 
9b53f70
 
 
 
 
 
5b27e49
9b53f70
 
 
d94104e
9b53f70
 
c55c292
 
 
 
 
 
 
 
 
00d2c12
 
 
 
 
 
 
 
 
 
 
 
c55c292
 
 
 
 
 
 
5b27e49
 
c55c292
5b27e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c55c292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b53f70
 
 
5b27e49
 
9b53f70
d94104e
9b53f70
 
 
b4fd22e
 
9b53f70
 
 
 
 
 
 
 
30b7d3a
 
 
b7ce941
 
 
9b53f70
 
 
 
 
 
c55c292
 
 
 
 
5b27e49
9b53f70
e014541
9b53f70
 
 
 
 
 
5b27e49
9b53f70
c55c292
 
 
9b53f70
ab852b0
 
788bc93
 
c55989f
 
 
4f9859d
d908e31
 
cabddd5
 
5b27e49
cabddd5
d94104e
cabddd5
 
5b27e49
9b53f70
 
 
3370310
 
 
5b27e49
 
 
3370310
9b53f70
3370310
 
 
5b27e49
 
 
 
 
9b53f70
3370310
9b53f70
 
3370310
5b27e49
9b53f70
 
 
 
3370310
9b53f70
 
5b27e49
 
 
 
 
 
 
 
 
 
 
 
 
 
9b53f70
 
 
 
 
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
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();
  });
}