tphim 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/package.json +14 -2
- package/pipeline.mjs +159 -16
- package/pro-terminal.mjs +27 -20
- package/scripts/gen-readme.mjs +69 -66
- package/scripts/gen_subtitle.py +5 -4
- package/TXA_AI_GUIDE.md +0 -25
package/.env.example
CHANGED
|
@@ -3,3 +3,7 @@ TEBI_ACCESS_KEY_ID=your_access_key
|
|
|
3
3
|
TEBI_SECRET_ACCESS_KEY=your_secret_key
|
|
4
4
|
TEBI_BUCKET=your_bucket_name
|
|
5
5
|
TEBI_PUBLIC_URL=https://s3.tebi.io/your_bucket_name
|
|
6
|
+
|
|
7
|
+
# Proxy rotation (Optional)
|
|
8
|
+
# Format: ip:port:user:pass, separated by |
|
|
9
|
+
PROXY_LIST=31.59.20.176:6754:user:pass|ip:port:user:pass
|
package/package.json
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tphim",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "TPHIM - Ultimate Video Pipeline: Download, Transcode HLS, AI Subtitles, and Cloud Upload.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"ntxa": "./pro-terminal.mjs"
|
|
9
9
|
},
|
|
10
|
+
"files": [
|
|
11
|
+
"pro-terminal.mjs",
|
|
12
|
+
"pipeline.mjs",
|
|
13
|
+
"scripts/",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE.md",
|
|
16
|
+
"index.js",
|
|
17
|
+
".env.example"
|
|
18
|
+
],
|
|
10
19
|
"scripts": {
|
|
11
20
|
"build": "node -e \"console.log('Building TPHIM by TXA... Integrity check passed.')\"",
|
|
12
21
|
"prepublishOnly": "node -e \"if(!require('fs').existsSync('.env.example')) throw new Error('Missing .env.example')\"",
|
|
@@ -44,10 +53,13 @@
|
|
|
44
53
|
"chalk": "^5.6.2",
|
|
45
54
|
"cli-progress": "^3.12.0",
|
|
46
55
|
"dotenv": "^17.3.1",
|
|
56
|
+
"ffmpeg-static": "^5.2.0",
|
|
57
|
+
"ffprobe-static": "^3.1.0",
|
|
47
58
|
"figlet": "^1.10.0",
|
|
48
59
|
"log-symbols": "^7.0.1",
|
|
49
60
|
"mime-types": "^3.0.2",
|
|
50
|
-
"ora": "^9.3.0"
|
|
61
|
+
"ora": "^9.3.0",
|
|
62
|
+
"yt-dlp-exec": "^1.0.2"
|
|
51
63
|
},
|
|
52
64
|
"funding": {
|
|
53
65
|
"type": "individual",
|
package/pipeline.mjs
CHANGED
|
@@ -3,12 +3,88 @@
|
|
|
3
3
|
// Usage: node pipeline.mjs "URL_hoac_duong_dan_mp4" "slug" "Tên Phim" [vi|en|both]
|
|
4
4
|
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
|
-
import { existsSync, mkdirSync, writeFileSync, rmSync, unlinkSync } from 'fs';
|
|
7
|
-
import
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, unlinkSync, readFileSync } from 'fs';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import path, { dirname } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
8
10
|
import 'dotenv/config';
|
|
11
|
+
import ffmpeg from 'ffmpeg-static';
|
|
9
12
|
import { uploadHLSFolder } from './scripts/upload-tebi.mjs';
|
|
10
13
|
import { generateReadme } from './scripts/gen-readme.mjs';
|
|
11
14
|
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const IS_WIN = os.platform() === 'win32';
|
|
17
|
+
const SHELL_OPTS = IS_WIN ? { shell: 'cmd.exe' } : {};
|
|
18
|
+
|
|
19
|
+
// Resolve binaries
|
|
20
|
+
const FFMPEG_BIN = ffmpeg || 'ffmpeg';
|
|
21
|
+
const YTDLP_BIN = 'yt-dlp'; // default to PATH if not found in project
|
|
22
|
+
|
|
23
|
+
// Search for local yt-dlp in node_modules if it exists
|
|
24
|
+
const localYtDlp = path.join(__dirname, 'node_modules', 'yt-dlp-exec', 'bin', 'yt-dlp.exe');
|
|
25
|
+
const localYtDlpUnix = path.join(__dirname, 'node_modules', 'yt-dlp-exec', 'bin', 'yt-dlp');
|
|
26
|
+
const FINAL_YTDLP = existsSync(localYtDlp) ? `"${localYtDlp}"` : (existsSync(localYtDlpUnix) ? localYtDlpUnix : YTDLP_BIN);
|
|
27
|
+
|
|
28
|
+
// ─── PROXY ROTATION ──────────────────────────────────────────────
|
|
29
|
+
// Parse a single proxy line: ip:port:user:pass → http://user:pass@ip:port
|
|
30
|
+
function parseProxyLine(line) {
|
|
31
|
+
const l = line.trim();
|
|
32
|
+
if (!l || l.startsWith('#')) return null;
|
|
33
|
+
|
|
34
|
+
// Already a URL format (http://... or socks5://...)
|
|
35
|
+
if (l.startsWith('http') || l.startsWith('socks')) return l;
|
|
36
|
+
|
|
37
|
+
const parts = l.split(':');
|
|
38
|
+
if (parts.length === 4) {
|
|
39
|
+
const [ip, port, user, pass] = parts;
|
|
40
|
+
return `http://${user}:${pass}@${ip}:${port}`;
|
|
41
|
+
} else if (parts.length === 2) {
|
|
42
|
+
return `http://${parts[0]}:${parts[1]}`;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadProxies() {
|
|
48
|
+
let proxies = [];
|
|
49
|
+
|
|
50
|
+
// Source 1: PROXY_LIST env variable (pipe | separated)
|
|
51
|
+
// Example: PROXY_LIST=ip:port:user:pass|ip:port:user:pass
|
|
52
|
+
if (process.env.PROXY_LIST) {
|
|
53
|
+
const envProxies = process.env.PROXY_LIST
|
|
54
|
+
.split('|')
|
|
55
|
+
.map(parseProxyLine)
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
proxies.push(...envProxies);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Source 2: proxies.txt file (fallback, one per line)
|
|
61
|
+
if (proxies.length === 0) {
|
|
62
|
+
const proxyFile = path.join(__dirname, 'proxies.txt');
|
|
63
|
+
if (existsSync(proxyFile)) {
|
|
64
|
+
try {
|
|
65
|
+
const fileProxies = readFileSync(proxyFile, 'utf-8')
|
|
66
|
+
.split(/\r?\n/)
|
|
67
|
+
.map(parseProxyLine)
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
proxies.push(...fileProxies);
|
|
70
|
+
} catch (_) { }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (proxies.length > 0) {
|
|
75
|
+
console.log(` 🔌 Loaded ${proxies.length} proxies (from ${process.env.PROXY_LIST ? '.env' : 'proxies.txt'})`);
|
|
76
|
+
}
|
|
77
|
+
return proxies;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getRandomProxy(proxies, exclude = []) {
|
|
81
|
+
const available = proxies.filter(p => !exclude.includes(p));
|
|
82
|
+
if (available.length === 0) return null;
|
|
83
|
+
return available[Math.floor(Math.random() * available.length)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const PROXY_LIST = loadProxies();
|
|
87
|
+
|
|
12
88
|
export async function run() {
|
|
13
89
|
// Nếu chạy trực tiếp từ CLI
|
|
14
90
|
let [, , input, slug, title = slug, langArg = 'vi'] = process.argv;
|
|
@@ -49,7 +125,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
49
125
|
|
|
50
126
|
// Preserve previous yt-dlp improvements
|
|
51
127
|
let referer = '';
|
|
52
|
-
let userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
128
|
+
let userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
53
129
|
|
|
54
130
|
try {
|
|
55
131
|
const urlObj = new URL(input);
|
|
@@ -58,12 +134,79 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
58
134
|
|
|
59
135
|
// Nếu là link S3/Tebi, dùng User-Agent sạch thay vì impersonate để tránh 403
|
|
60
136
|
const isS3 = input.includes('s3.tebi.io') || input.includes('amazonaws.com');
|
|
61
|
-
const dlCmd = isS3
|
|
62
|
-
? `yt-dlp --no-check-certificate --user-agent "${userAgent}" --no-part --retries 10 --fragment-retries 10 --concurrent-fragments 5 "${input}" -o "${mp4Path}"`
|
|
63
|
-
: `yt-dlp --extractor-args "generic:impersonate=chrome" --referer "${referer || 'https://embed1.streamc.xyz/'}" --no-part --retries 10 --fragment-retries 10 --concurrent-fragments 5 "${input}" -o "${mp4Path}"`;
|
|
64
137
|
|
|
65
|
-
|
|
66
|
-
|
|
138
|
+
// Common flags for all downloads
|
|
139
|
+
const commonFlags = [
|
|
140
|
+
'--no-check-certificate',
|
|
141
|
+
`--user-agent "${userAgent}"`,
|
|
142
|
+
'--no-part',
|
|
143
|
+
'--retries 10',
|
|
144
|
+
'--fragment-retries 10',
|
|
145
|
+
'--concurrent-fragments 5',
|
|
146
|
+
].join(' ');
|
|
147
|
+
|
|
148
|
+
// Build base command (without proxy)
|
|
149
|
+
const buildDlCmd = (proxyUrl) => {
|
|
150
|
+
const proxyFlag = proxyUrl ? `--proxy "${proxyUrl}"` : '';
|
|
151
|
+
if (isS3) {
|
|
152
|
+
return `${FINAL_YTDLP} ${proxyFlag} ${commonFlags} "${input}" -o "${mp4Path}"`;
|
|
153
|
+
} else {
|
|
154
|
+
const refFlag = referer ? `--referer "${referer}"` : '--referer "https://embed1.streamc.xyz/"';
|
|
155
|
+
return `${FINAL_YTDLP} --extractor-args "generic:impersonate=chrome" ${refFlag} ${proxyFlag} ${commonFlags} "${input}" -o "${mp4Path}"`;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let downloaded = false;
|
|
160
|
+
|
|
161
|
+
// Attempt 1: Direct download (no proxy)
|
|
162
|
+
try {
|
|
163
|
+
console.log(' 🌐 Thử download trực tiếp...');
|
|
164
|
+
execSync(buildDlCmd(null), { stdio: 'inherit' });
|
|
165
|
+
downloaded = true;
|
|
166
|
+
console.log(' ✅ Download xong (direct)');
|
|
167
|
+
} catch (_) {
|
|
168
|
+
try { if (existsSync(mp4Path)) unlinkSync(mp4Path); } catch (__) { }
|
|
169
|
+
console.log(' ⚠ Download trực tiếp thất bại.');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Attempt 2-4: Retry with proxy rotation
|
|
173
|
+
if (!downloaded && PROXY_LIST.length > 0) {
|
|
174
|
+
const MAX_PROXY_RETRIES = Math.min(3, PROXY_LIST.length);
|
|
175
|
+
const usedProxies = [];
|
|
176
|
+
console.log(` 🔄 Thử lại với proxy (${PROXY_LIST.length} proxies available)...`);
|
|
177
|
+
|
|
178
|
+
for (let attempt = 1; attempt <= MAX_PROXY_RETRIES; attempt++) {
|
|
179
|
+
const proxy = getRandomProxy(PROXY_LIST, usedProxies);
|
|
180
|
+
if (!proxy) break;
|
|
181
|
+
usedProxies.push(proxy);
|
|
182
|
+
|
|
183
|
+
// Mask credentials in log
|
|
184
|
+
const maskedProxy = proxy.replace(/:([^@:]+)@/, ':***@');
|
|
185
|
+
console.log(` 🔀 [Proxy ${attempt}/${MAX_PROXY_RETRIES}] ${maskedProxy}`);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
execSync(buildDlCmd(proxy), { stdio: 'inherit' });
|
|
189
|
+
downloaded = true;
|
|
190
|
+
console.log(` ✅ Download xong (via proxy)`);
|
|
191
|
+
break;
|
|
192
|
+
} catch (_) {
|
|
193
|
+
try { if (existsSync(mp4Path)) unlinkSync(mp4Path); } catch (__) { }
|
|
194
|
+
console.log(` ⚠ Proxy ${attempt} thất bại, ${attempt < MAX_PROXY_RETRIES ? 'thử proxy khác...' : 'hết proxy.'}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!downloaded) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`❌ Download thất bại: ${input}\n` +
|
|
202
|
+
` Đã thử: direct + ${Math.min(3, PROXY_LIST.length)} proxies\n` +
|
|
203
|
+
` Nguyên nhân phổ biến:\n` +
|
|
204
|
+
` • Link đã hết hạn hoặc bị xóa (HTTP 404)\n` +
|
|
205
|
+
` • Server CDN chặn tất cả IP\n` +
|
|
206
|
+
` • Link cần cookie/token đặc biệt\n` +
|
|
207
|
+
` → Hãy thử mở link trong trình duyệt để kiểm tra.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
67
210
|
} else {
|
|
68
211
|
console.log(`\n⏭ [1/5] File đã có sẵn: ${mp4Path}`);
|
|
69
212
|
}
|
|
@@ -109,7 +252,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
109
252
|
});
|
|
110
253
|
|
|
111
254
|
const ffmpegCmd = [
|
|
112
|
-
|
|
255
|
+
`"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`, '-threads 0',
|
|
113
256
|
maps,
|
|
114
257
|
outputs,
|
|
115
258
|
'-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
|
|
@@ -119,14 +262,14 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
119
262
|
`"${outDir}/%v/index.m3u8"`
|
|
120
263
|
].join(' ');
|
|
121
264
|
|
|
122
|
-
execSync(ffmpegCmd, { stdio: 'inherit',
|
|
265
|
+
execSync(ffmpegCmd, { stdio: 'inherit', ...SHELL_OPTS });
|
|
123
266
|
console.log(' ✅ HLS Master generated.');
|
|
124
267
|
|
|
125
268
|
// --- BƯỚC 2.1: XUẤT 3GP CHO MÁY CỔ (NOKIA S40/S60) ---
|
|
126
269
|
console.log(' 📱 [2.1/5] Exporting 3GP legacy format...');
|
|
127
|
-
const nokiaCmd = `
|
|
270
|
+
const nokiaCmd = `"${FFMPEG_BIN}" -i "${mp4Path}" -s 320x240 -vcodec mpeg4 -acodec aac -ar 16000 -ac 1 -b:v 250k -b:a 32k -f 3gp "${nokia3gpPath}"`;
|
|
128
271
|
try {
|
|
129
|
-
execSync(nokiaCmd, { stdio: 'ignore',
|
|
272
|
+
execSync(nokiaCmd, { stdio: 'ignore', ...SHELL_OPTS });
|
|
130
273
|
console.log(' ✅ Legacy 3GP ready.');
|
|
131
274
|
} catch (e) {
|
|
132
275
|
console.log(' ⚠ 3GP generation failed.');
|
|
@@ -138,9 +281,9 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
138
281
|
// --- STEP 2.5: PREVIEW SPRITE --- (Independent check)
|
|
139
282
|
if (!existsSync(spritePath)) {
|
|
140
283
|
console.log(' 📸 [2.5/5] Generating Neon Preview SpriteSheet...');
|
|
141
|
-
const spriteCmd = `
|
|
284
|
+
const spriteCmd = `"${FFMPEG_BIN}" -i "${mp4Path}" -vf "fps=1/10,scale=160:90,tile=10x10" -q:v 3 "${spritePath}"`;
|
|
142
285
|
try {
|
|
143
|
-
execSync(spriteCmd, { stdio: 'ignore',
|
|
286
|
+
execSync(spriteCmd, { stdio: 'ignore', ...SHELL_OPTS });
|
|
144
287
|
console.log(' ✅ Preview SpriteSheet ready.');
|
|
145
288
|
} catch (e) { console.log(' ⚠ Preview generation failed.'); }
|
|
146
289
|
} else {
|
|
@@ -167,7 +310,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
167
310
|
}
|
|
168
311
|
|
|
169
312
|
execSync(
|
|
170
|
-
`${pythonCmd} ${path.join('scripts', 'gen_subtitle.py')} "${mp4Path}" "${slug}" ${lang}`,
|
|
313
|
+
`${pythonCmd} ${path.join(__dirname, 'scripts', 'gen_subtitle.py')} "${mp4Path}" "${slug}" ${lang} "${FFMPEG_BIN}"`,
|
|
171
314
|
{ stdio: 'inherit', cwd: process.cwd() }
|
|
172
315
|
);
|
|
173
316
|
} else {
|
|
@@ -177,7 +320,7 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
|
|
|
177
320
|
|
|
178
321
|
// ─── BƯỚC 5: Upload Tebi.io ─────────────────────────────────── // --- STEP 5: UPLOAD ---
|
|
179
322
|
console.log('\n🚀 [5/5] Deploying to Quantum Cloud (Tebi.io)...');
|
|
180
|
-
execSync(`node ${path.join('scripts', 'upload-tebi.mjs')} "${slug}"`, { stdio: 'inherit' });
|
|
323
|
+
execSync(`node ${path.join(__dirname, 'scripts', 'upload-tebi.mjs')} "${slug}"`, { stdio: 'inherit' });
|
|
181
324
|
|
|
182
325
|
// --- FINAL STEP: GENERATE MASTER README.md ---
|
|
183
326
|
const finalReadmePath = path.join('hls', slug, 'README.md');
|
package/pro-terminal.mjs
CHANGED
|
@@ -14,9 +14,16 @@ import { copyFileSync, existsSync } from 'fs';
|
|
|
14
14
|
import { join, dirname } from 'path';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
import 'dotenv/config';
|
|
17
|
+
import ffmpeg from 'ffmpeg-static';
|
|
17
18
|
|
|
18
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
|
|
21
|
+
// Resolve binaries
|
|
22
|
+
const FFMPEG_BIN = ffmpeg || 'ffmpeg';
|
|
23
|
+
const localYtDlp = join(__dirname, 'node_modules', 'yt-dlp-exec', 'bin', 'yt-dlp.exe');
|
|
24
|
+
const localYtDlpUnix = join(__dirname, 'node_modules', 'yt-dlp-exec', 'bin', 'yt-dlp');
|
|
25
|
+
const FINAL_YTDLP = existsSync(localYtDlp) ? `"${localYtDlp}"` : (existsSync(localYtDlpUnix) ? localYtDlpUnix : 'yt-dlp');
|
|
26
|
+
|
|
20
27
|
// Auto-copy .env.example to .env if .env doesn't exist
|
|
21
28
|
const envPath = join(__dirname, '.env');
|
|
22
29
|
const envExamplePath = join(__dirname, '.env.example');
|
|
@@ -54,21 +61,21 @@ function extractTitleFromUrl(url) {
|
|
|
54
61
|
const urlParts = url.split('/').filter(p => p && p !== 'http:' && p !== 'https:');
|
|
55
62
|
const lastPart = urlParts[urlParts.length - 1];
|
|
56
63
|
const secondLastPart = urlParts[urlParts.length - 2];
|
|
57
|
-
|
|
64
|
+
|
|
58
65
|
// Clean up parts
|
|
59
66
|
const cleanLast = lastPart?.replace(/\.(mp4|m3u8|mkv|avi|mov|flv|webm|html|htm)$/i, '').replace(/[_\-]/g, ' ').trim();
|
|
60
67
|
const cleanSecond = secondLastPart?.replace(/[_\-]/g, ' ').trim();
|
|
61
|
-
|
|
68
|
+
|
|
62
69
|
// Prefer second to last part (usually folder/slug structure)
|
|
63
70
|
if (cleanSecond && cleanSecond.length > 2 && !['index', 'master', 'video', 'movie', 'play'].includes(cleanSecond.toLowerCase())) {
|
|
64
71
|
return cleanSecond;
|
|
65
72
|
}
|
|
66
|
-
|
|
73
|
+
|
|
67
74
|
// Fallback to last part
|
|
68
75
|
if (cleanLast && cleanLast.length > 2 && !['index', 'master', 'video', 'movie', 'play'].includes(cleanLast.toLowerCase())) {
|
|
69
76
|
return cleanLast;
|
|
70
77
|
}
|
|
71
|
-
|
|
78
|
+
|
|
72
79
|
return `movie-${Date.now()}`;
|
|
73
80
|
}
|
|
74
81
|
|
|
@@ -103,14 +110,14 @@ function showHelp() {
|
|
|
103
110
|
})
|
|
104
111
|
)
|
|
105
112
|
);
|
|
106
|
-
|
|
113
|
+
|
|
107
114
|
console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v1.0 | HELP SYSTEM ⚡\n'));
|
|
108
|
-
|
|
115
|
+
|
|
109
116
|
console.log(`${neonPurple('📖 CÁCH SỬ DỤNG:')}`);
|
|
110
117
|
console.log(chalk.cyan(' ntxa help - Hiển thị help này'));
|
|
111
118
|
console.log(chalk.cyan(' ntxa - Chạy interactive mode'));
|
|
112
119
|
console.log(chalk.cyan(' ntxa "Tên Phim" - Chạy với tên phim mặc định\n'));
|
|
113
|
-
|
|
120
|
+
|
|
114
121
|
console.log(`${neonPurple('🔧 CẤU HÌNH BẮT BUỘC:')}`);
|
|
115
122
|
console.log(chalk.yellow(' File .env với các biến:'));
|
|
116
123
|
console.log(chalk.cyan(' TEBI_ENDPOINT - API endpoint Tebi.io'));
|
|
@@ -118,7 +125,7 @@ function showHelp() {
|
|
|
118
125
|
console.log(chalk.cyan(' TEBI_SECRET_ACCESS_KEY - Secret key'));
|
|
119
126
|
console.log(chalk.cyan(' TEBI_BUCKET - Bucket name'));
|
|
120
127
|
console.log(chalk.cyan(' TEBI_PUBLIC_URL - Public URL\n'));
|
|
121
|
-
|
|
128
|
+
|
|
122
129
|
console.log(`${neonPurple('⚡ TÍNH NĂNG:')}`);
|
|
123
130
|
console.log(chalk.cyan(' • Download video từ multiple sources'));
|
|
124
131
|
console.log(chalk.cyan(' • Transcode sang HLS format'));
|
|
@@ -126,23 +133,23 @@ function showHelp() {
|
|
|
126
133
|
console.log(chalk.cyan(' • Upload cloud storage (Tebi.io)'));
|
|
127
134
|
console.log(chalk.cyan(' • Batch processing support'));
|
|
128
135
|
console.log(chalk.cyan(' • Multi-language subtitles (VI/EN)\n'));
|
|
129
|
-
|
|
136
|
+
|
|
130
137
|
console.log(`${neonPurple('👤 DEVELOPER:')}`);
|
|
131
138
|
console.log(chalk.cyan(' TXA - Ultimate Video Pipeline 2030'));
|
|
132
139
|
console.log(chalk.gray(' Licensed under ISC\n'));
|
|
133
|
-
|
|
140
|
+
|
|
134
141
|
console.log(`${neonPink('🚀 Ready to rock! Try: ntxa')}`);
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
async function main() {
|
|
138
145
|
const args = process.argv.slice(2);
|
|
139
|
-
|
|
146
|
+
|
|
140
147
|
// Check for help command
|
|
141
148
|
if (args.includes('help') || args.includes('--help') || args.includes('-h')) {
|
|
142
149
|
showHelp();
|
|
143
150
|
return;
|
|
144
151
|
}
|
|
145
|
-
|
|
152
|
+
|
|
146
153
|
checkEnv();
|
|
147
154
|
console.clear();
|
|
148
155
|
|
|
@@ -172,14 +179,14 @@ async function main() {
|
|
|
172
179
|
hint: chalk.yellow('Separate multiple links with COMMA (,)'),
|
|
173
180
|
validate: (value) => {
|
|
174
181
|
if (!value) return 'System requires a data source.';
|
|
175
|
-
|
|
182
|
+
|
|
176
183
|
const links = value.split(',').map(l => l.trim()).filter(l => l);
|
|
177
184
|
const invalidLinks = links.filter(link => !validateUrl(link));
|
|
178
|
-
|
|
185
|
+
|
|
179
186
|
if (invalidLinks.length > 0) {
|
|
180
187
|
return `Invalid URL(s): ${invalidLinks.join(', ')}`;
|
|
181
188
|
}
|
|
182
|
-
|
|
189
|
+
|
|
183
190
|
return undefined;
|
|
184
191
|
},
|
|
185
192
|
}),
|
|
@@ -232,28 +239,28 @@ async function main() {
|
|
|
232
239
|
try {
|
|
233
240
|
s.message('🛰️ Fetching metadata...');
|
|
234
241
|
const { execSync } = await import('child_process');
|
|
235
|
-
|
|
242
|
+
|
|
236
243
|
// Try multiple methods to get title
|
|
237
244
|
let fetchedTitle = '';
|
|
238
|
-
|
|
245
|
+
|
|
239
246
|
// Method 1: Try yt-dlp title
|
|
240
247
|
try {
|
|
241
248
|
fetchedTitle = execSync(
|
|
242
|
-
|
|
249
|
+
`${FINAL_YTDLP} --print title --skip-download "${inputUrl}"`,
|
|
243
250
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }
|
|
244
251
|
).trim();
|
|
245
252
|
} catch (e1) {
|
|
246
253
|
// Method 2: Try yt-dlp with different flags
|
|
247
254
|
try {
|
|
248
255
|
fetchedTitle = execSync(
|
|
249
|
-
|
|
256
|
+
`${FINAL_YTDLP} --get-title --no-download "${inputUrl}"`,
|
|
250
257
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }
|
|
251
258
|
).trim();
|
|
252
259
|
} catch (e2) {
|
|
253
260
|
// Method 3: Try generic approach
|
|
254
261
|
try {
|
|
255
262
|
const info = execSync(
|
|
256
|
-
|
|
263
|
+
`${FINAL_YTDLP} --dump-json --no-download "${inputUrl}"`,
|
|
257
264
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 }
|
|
258
265
|
);
|
|
259
266
|
const parsed = JSON.parse(info);
|
package/scripts/gen-readme.mjs
CHANGED
|
@@ -3,72 +3,75 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execSync } from 'child_process';
|
|
5
5
|
import 'dotenv/config';
|
|
6
|
+
import ffprobe from 'ffprobe-static';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
const hlsDir = path.join('hls', slug);
|
|
9
|
-
|
|
10
|
-
function countFiles(dir, ext) {
|
|
11
|
-
if (!fs.existsSync(dir)) return 0;
|
|
12
|
-
let count = 0;
|
|
13
|
-
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
-
const full = path.join(dir, item.name);
|
|
15
|
-
if (item.isDirectory()) count += countFiles(full, ext);
|
|
16
|
-
else if (item.name.endsWith(ext)) count++;
|
|
17
|
-
}
|
|
18
|
-
return count;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function getVideoDuration(filePath) {
|
|
22
|
-
try {
|
|
23
|
-
const sec = parseFloat(
|
|
24
|
-
execSync(`ffprobe -i "${filePath}" -show_entries format=duration -v quiet -of csv=p=0`,
|
|
25
|
-
{ stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim()
|
|
26
|
-
);
|
|
27
|
-
const h = Math.floor(sec / 3600);
|
|
28
|
-
const m = Math.floor((sec % 3600) / 60);
|
|
29
|
-
const s = Math.floor(sec % 60);
|
|
30
|
-
return `${h > 0 ? h + 'h ' : ''}${m}m ${s}s (${Math.round(sec)}s)`;
|
|
31
|
-
} catch { return 'N/A'; }
|
|
32
|
-
}
|
|
8
|
+
const FFPROBE_BIN = ffprobe.path || 'ffprobe';
|
|
33
9
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
10
|
+
export function generateReadme(slug, mp4Path, info = {}) {
|
|
11
|
+
const hlsDir = path.join('hls', slug);
|
|
12
|
+
|
|
13
|
+
function countFiles(dir, ext) {
|
|
14
|
+
if (!fs.existsSync(dir)) return 0;
|
|
15
|
+
let count = 0;
|
|
16
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
17
|
+
const full = path.join(dir, item.name);
|
|
18
|
+
if (item.isDirectory()) count += countFiles(full, ext);
|
|
19
|
+
else if (item.name.endsWith(ext)) count++;
|
|
43
20
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
21
|
+
return count;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getVideoDuration(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
const sec = parseFloat(
|
|
27
|
+
execSync(`"${FFPROBE_BIN}" -i "${filePath}" -show_entries format=duration -v quiet -of csv=p=0`,
|
|
28
|
+
{ stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim()
|
|
29
|
+
);
|
|
30
|
+
const h = Math.floor(sec / 3600);
|
|
31
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
32
|
+
const s = Math.floor(sec % 60);
|
|
33
|
+
return `${h > 0 ? h + 'h ' : ''}${m}m ${s}s (${Math.round(sec)}s)`;
|
|
34
|
+
} catch { return 'N/A'; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getFolderSizeMB(dir) {
|
|
38
|
+
let total = 0;
|
|
39
|
+
if (!fs.existsSync(dir)) return '0 MB';
|
|
40
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
41
|
+
const full = path.join(dir, item.name);
|
|
42
|
+
if (item.isDirectory()) total += parseFloat(getFolderSizeMB(full));
|
|
43
|
+
else total += fs.statSync(full).size / (1024 * 1024);
|
|
59
44
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
45
|
+
return total.toFixed(1) + ' MB';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getStreamInfo(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
const json = JSON.parse(execSync(
|
|
51
|
+
`"${FFPROBE_BIN}" -v quiet -print_format json -show_streams "${filePath}"`,
|
|
52
|
+
{ stdio: ['pipe', 'pipe', 'pipe'] }).toString());
|
|
53
|
+
const video = json.streams.find(s => s.codec_type === 'video');
|
|
54
|
+
const audio = json.streams.find(s => s.codec_type === 'audio');
|
|
55
|
+
return {
|
|
56
|
+
resolution: video ? `${video.width}x${video.height}` : 'N/A',
|
|
57
|
+
videoCodec: video ? video.codec_name.toUpperCase() : 'N/A',
|
|
58
|
+
audioCodec: audio ? audio.codec_name.toUpperCase() : 'N/A',
|
|
59
|
+
fps: video ? Math.round(eval(video.r_frame_rate)) + ' fps' : 'N/A',
|
|
60
|
+
};
|
|
61
|
+
} catch { return { resolution: 'N/A', videoCodec: 'N/A', audioCodec: 'N/A', fps: 'N/A' }; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const duration = getVideoDuration(mp4Path);
|
|
65
|
+
const totalSize = getFolderSizeMB(hlsDir);
|
|
66
|
+
const tsCount = countFiles(hlsDir, '.ts');
|
|
67
|
+
const hasSub = fs.existsSync(path.join(hlsDir, 'vi.vtt'));
|
|
68
|
+
const hasSubEn = fs.existsSync(path.join(hlsDir, 'en.vtt'));
|
|
69
|
+
const streamInfo = getStreamInfo(mp4Path);
|
|
70
|
+
const now = new Date().toLocaleString('vi-VN', { timeZone: 'Asia/Ho_Chi_Minh' });
|
|
71
|
+
const publicUrl = process.env.TEBI_PUBLIC_URL || 'https://s3.tebi.io/nrotxa-videos';
|
|
72
|
+
|
|
73
|
+
const readme =
|
|
74
|
+
`================================================================================
|
|
72
75
|
THÔNG TIN FOLDER: hls/${slug}
|
|
73
76
|
Tạo lúc: ${now}
|
|
74
77
|
================================================================================
|
|
@@ -112,8 +115,8 @@ export function generateReadme(slug, mp4Path, info = {}) {
|
|
|
112
115
|
================================================================================
|
|
113
116
|
`;
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
const readmePath = path.join(hlsDir, 'README.txt');
|
|
119
|
+
fs.writeFileSync(readmePath, readme, 'utf8');
|
|
120
|
+
console.log(` ✅ README.txt saved: ${readmePath}`);
|
|
121
|
+
return readmePath;
|
|
119
122
|
}
|
package/scripts/gen_subtitle.py
CHANGED
|
@@ -8,11 +8,11 @@ import subprocess
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from faster_whisper import WhisperModel
|
|
10
10
|
|
|
11
|
-
def extract_audio(video_path, audio_path):
|
|
11
|
+
def extract_audio(video_path, audio_path, ffmpeg_path="ffmpeg"):
|
|
12
12
|
"""Trích audio nhẹ từ video để whisper xử lý nhanh hơn."""
|
|
13
|
-
print(" ↳ Extracting audio
|
|
13
|
+
print(f" ↳ Extracting audio using: {ffmpeg_path}")
|
|
14
14
|
subprocess.run([
|
|
15
|
-
|
|
15
|
+
ffmpeg_path, "-i", video_path,
|
|
16
16
|
"-vn", "-acodec", "pcm_s16le",
|
|
17
17
|
"-ar", "16000", "-ac", "1",
|
|
18
18
|
audio_path, "-y"
|
|
@@ -66,11 +66,12 @@ def main():
|
|
|
66
66
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
67
67
|
|
|
68
68
|
audio_path = f"temp_audio_{slug}.wav"
|
|
69
|
+
ffmpeg_path = sys.argv[4] if len(sys.argv) > 4 else "ffmpeg"
|
|
69
70
|
|
|
70
71
|
print(f"\n📝 Generating subtitle [{lang}] for: {slug}")
|
|
71
72
|
|
|
72
73
|
try:
|
|
73
|
-
extract_audio(video_path, audio_path)
|
|
74
|
+
extract_audio(video_path, audio_path, ffmpeg_path)
|
|
74
75
|
segments = transcribe(audio_path, lang)
|
|
75
76
|
vtt = segments_to_vtt(segments)
|
|
76
77
|
|
package/TXA_AI_GUIDE.md
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# 🤖 TXA NEON CORE 2030 - AI DEVELOPER GUIDE
|
|
2
|
-
|
|
3
|
-
Tài liệu này dành cho AI để hiểu cấu trúc, logic và cách mở rộng hệ sinh thái **TXA Neon Core** (phiên bản VOD Pipeline + TXAPlayer Pro V6.4).
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## 🚀 2. LOGIC XỬ LÝ STREAM (M3U8 & PROXY)
|
|
8
|
-
|
|
9
|
-
`TXAPlayer.js` hiện tại không chỉ là UI, nó có bộ não xử lý stream mạnh mẽ:
|
|
10
|
-
- **Direct M3U8 Processing:** Player có hàm `getM3u8TsListFast()` để phân tích playlist, tự động chọn chất lượng cao nhất (Variant Selection) và thậm chí ước tính dung lượng phim.
|
|
11
|
-
- **Dynamic Quality Management:** Player hỗ trợ full dải chất lượng từ 144p, 248p (3GP cho máy Java), 480p SD đến 4K.
|
|
12
|
-
- **Admin Hard-Sync:** Hệ thống đồng bộ chặt chẽ với cấu hình từ Backend/Admin. Tham số `maxQuality` sẽ lọc bỏ các chất lượng không được phép trước khi hiển thị trong Menu Quality.
|
|
13
|
-
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
## 🛠️ 3. CẤU TRÚC LINK STREAM (M3U8 STRUCTURE)
|
|
17
|
-
|
|
18
|
-
Khi tích hợp, AI cần hiểu link `videoUrl` truyền vào `TXAPlayer` có thể là:
|
|
19
|
-
|
|
20
|
-
1. **Direct S3/Tebi URL:**
|
|
21
|
-
- Định dạng: `https://[TEBI_PUBLIC_URL]/[movie-slug]/[episode]/index.m3u8`
|
|
22
|
-
- Đặc điểm: Đây là file Master Playlist chứa danh sách các chất lượng (360p, 720p, 1080p).
|
|
23
|
-
|
|
24
|
-
**Quy trình xử lý của Player:**
|
|
25
|
-
- `Master Playlist` (.m3u8) -> `Variant Playlist` (360p/720p...) -> `TS Segments` (.ts) hoặc `Fragments` (.m4s).
|