tphim 1.0.0

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 ADDED
@@ -0,0 +1,5 @@
1
+ TEBI_ENDPOINT=https://s3.tebi.io
2
+ TEBI_ACCESS_KEY_ID=your_access_key
3
+ TEBI_SECRET_ACCESS_KEY=your_secret_key
4
+ TEBI_BUCKET=your_bucket_name
5
+ TEBI_PUBLIC_URL=https://s3.tebi.io/your_bucket_name
package/LICENSE.md ADDED
@@ -0,0 +1,20 @@
1
+ TPHIM - PROPRIETARY LICENSE AGREEMENT
2
+
3
+ Copyright (c) 2026 TXA
4
+
5
+ This software is proprietary and confidential. All rights are reserved.
6
+
7
+ RESTRICTIONS:
8
+ - You may NOT copy, modify, distribute, or create derivative works
9
+ - You may NOT decompile, reverse engineer, or attempt to extract source code
10
+ - You may NOT use this software for commercial purposes without explicit written permission
11
+ - You may NOT resell, sublicense, or transfer this license to any third party
12
+
13
+ PERMISSIONS:
14
+ - You may install and use this software for personal use only
15
+ - You may use this software according to the documentation provided
16
+
17
+ VIOLATION:
18
+ Any violation of this license agreement may result in legal action and damages.
19
+
20
+ For commercial licensing inquiries, contact: txafile@gmail.com
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # 💎 TXA NEON CORE 2030
2
+
3
+ **TXA Neon Core** là bộ công cụ tối thượng để xây dựng hệ thống VOD (Video on Demand) chuyên nghiệp. Hỗ trợ tự động hóa từ khâu tải video, transcode HLS đa chất lượng, tạo phụ đề AI và upload lên Cloud (S3/Tebi).
4
+
5
+ ## 🚀 Tính năng vượt trội
6
+ - **Batch Pipeline:** Xử lý hàng loạt phim chỉ với 1 dòng lệnh.
7
+ - **AI Subtitles:** Tự động tạo phụ đề Tiếng Việt/Tiếng Anh bằng công cụ AI (Whisper) chạy offline.
8
+ - **Neon Hybrid Player:** Trình phát V5 hỗ trợ tận răng cho cả Desktop và Mobile với cử chỉ vuốt cực xịn.
9
+ - **Cloud Ready:** Upload trực tiếp lên Tebi.io hoặc bất kỳ S3-compatible storage nào.
10
+
11
+ ---
12
+
13
+ ## 🛠️ Hướng dẫn cài đặt (VPS Ubuntu / Google Cloud / Linux)
14
+
15
+ Để chạy được bộ công cụ này trên Linux/VPS, bạn cần cài đặt các "vũ khí" sau:
16
+
17
+ ### 1. Cài đặt Node.js
18
+ ```bash
19
+ curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
20
+ sudo apt-get install -y nodejs
21
+ ```
22
+
23
+ ### 2. Cài đặt FFmpeg & Python
24
+ ```bash
25
+ sudo apt update
26
+ sudo apt install -y ffmpeg python3 python3-pip
27
+ ```
28
+
29
+ ### 3. Cài đặt yt-dlp (Để tải video)
30
+ ```bash
31
+ sudo wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp
32
+ sudo chmod a+rx /usr/local/bin/yt-dlp
33
+ ```
34
+
35
+ ### 4. Cài đặt thư viện AI (faster-whisper)
36
+ ```bash
37
+ pip3 install faster-whisper
38
+ ```
39
+
40
+ ---
41
+
42
+ ## 📱 Hướng dẫn trên Android (Termux)
43
+
44
+ Bạn hoàn toàn có thể chạy Pipeline này ngay trên điện thoại:
45
+
46
+ 1. **Cài đặt môi trường:**
47
+ ```bash
48
+ pkg update && pkg upgrade
49
+ pkg install nodejs ffmpeg python python-pip wget
50
+ ```
51
+ 2. **Cài đặt yt-dlp:**
52
+ ```bash
53
+ pip install yt-dlp
54
+ ```
55
+ 3. **Cài đặt thư viện AI:**
56
+ (Lưu ý: faster-whisper trên Termux có thể cần cấu hình thêm tùy dòng máy Android).
57
+
58
+ ---
59
+
60
+ ## 📦 Sử dụng như một Thư viện (Library)
61
+
62
+ Cài đặt vào dự án của bạn:
63
+ ```bash
64
+ npm install txa-neon-core
65
+ ```
66
+
67
+ Trong code Node.js:
68
+ ```javascript
69
+ import { runPipeline } from 'txa-neon-core';
70
+
71
+ await runPipeline({
72
+ input: "https://link-phim.com/phim.m3u8",
73
+ slug: "phim-hay-2030",
74
+ title: "Phim Hay 2030",
75
+ langArg: "both"
76
+ });
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 🎬 Tích hợp TXAPlayer vào Website
82
+
83
+ Link trực tiếp mã nguồn trình phát:
84
+ `node_modules/txa-neon-core/TXAPlayer.js`
85
+
86
+ ```html
87
+ <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
88
+ <script src="path/to/TXAPlayer.js"></script>
89
+
90
+ <div id="player-container"></div>
91
+
92
+ <script>
93
+ new TXAPlayer("player-container", "https://s3.tebi.io/.../master.m3u8");
94
+ </script>
95
+ ```
96
+
97
+ ---
98
+
99
+ ## ⌨️ Sử dụng CLI (Terminal)
100
+
101
+ Sau khi cài đặt gói globally (`npm install -g`), bạn có thể dùng lệnh:
102
+
103
+ ```bash
104
+ ntxa "titile phim"
105
+ # Hoặc chạy batch mode
106
+ ntxa
107
+ ```
108
+
109
+ *Sản phẩm được phát triển bởi Antigravity AI Engine v2030.* 🍿🎬
@@ -0,0 +1,25 @@
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).
package/index.js ADDED
@@ -0,0 +1,32 @@
1
+ // index.js
2
+ /**
3
+ * TXA NEON CORE 2030
4
+ * The ultimate video processing pipeline and player library
5
+ */
6
+
7
+ import { executePipeline } from './pipeline.mjs';
8
+ import { readFileSync } from 'fs';
9
+ import { join, dirname } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+
14
+ /**
15
+ * Node-side API: Run the full VOD pipeline.
16
+ * @param {Object} options - pipeline options { input, slug, title, langArg }
17
+ */
18
+ export const runPipeline = executePipeline;
19
+
20
+ /**
21
+ * Returns the raw source code of TXAPlayer.js to be used in frontend.
22
+ */
23
+ export function getPlayerSource() {
24
+ return readFileSync(join(__dirname, 'TXAPlayer.js'), 'utf8');
25
+ }
26
+
27
+ /**
28
+ * Information for Frontend integration
29
+ */
30
+ export const browserAssets = {
31
+ js: join(__dirname, 'TXAPlayer.js')
32
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "tphim",
3
+ "version": "1.0.0",
4
+ "description": "TPHIM - Ultimate Video Pipeline: Download, Transcode HLS, AI Subtitles, and Cloud Upload.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "ntxa": "./pro-terminal.mjs"
9
+ },
10
+ "scripts": {
11
+ "build": "node -e \"console.log('Building TPHIM by TXA... Integrity check passed.')\"",
12
+ "prepublishOnly": "node -e \"if(!require('fs').existsSync('.env.example')) throw new Error('Missing .env.example')\"",
13
+ "test": "echo \"Ready for production by TXA\""
14
+ },
15
+ "keywords": [
16
+ "hls",
17
+ "ffmpeg",
18
+ "whisper",
19
+ "tebi",
20
+ "s3",
21
+ "video-processing",
22
+ "vod",
23
+ "automation",
24
+ "tphim",
25
+ "txa"
26
+ ],
27
+ "author": {
28
+ "name": "TXA",
29
+ "email": "txafile@gmail.com",
30
+ "url": "https://nrotxa.online"
31
+ },
32
+ "license": "SEE LICENSE IN LICENSE.md",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/TXAVLOG/TPHIM-DOWN"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/TXAVLOG/TPHIM-DOWN/issues"
39
+ },
40
+ "homepage": "https://film.nrotxa.online",
41
+ "dependencies": {
42
+ "@aws-sdk/client-s3": "^3.999.0",
43
+ "@clack/prompts": "^1.0.1",
44
+ "chalk": "^5.6.2",
45
+ "cli-progress": "^3.12.0",
46
+ "dotenv": "^17.3.1",
47
+ "figlet": "^1.10.0",
48
+ "log-symbols": "^7.0.1",
49
+ "mime-types": "^3.0.2",
50
+ "ora": "^9.3.0"
51
+ }
52
+ }
package/pipeline.mjs ADDED
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ // pipeline.mjs
3
+ // Usage: node pipeline.mjs "URL_hoac_duong_dan_mp4" "slug" "Tên Phim" [vi|en|both]
4
+
5
+ import { execSync } from 'child_process';
6
+ import { existsSync, mkdirSync, writeFileSync, rmSync, unlinkSync } from 'fs';
7
+ import path from 'path';
8
+ import 'dotenv/config';
9
+ import { uploadHLSFolder } from './scripts/upload-tebi.mjs';
10
+ import { generateReadme } from './scripts/gen-readme.mjs';
11
+
12
+ export async function run() {
13
+ // Nếu chạy trực tiếp từ CLI
14
+ let [, , input, slug, title = slug, langArg = 'vi'] = process.argv;
15
+
16
+ // Hỗ trợ truyền args vào function trong tương lai nếu cần
17
+ return await executePipeline({ input, slug, title, langArg });
18
+ }
19
+
20
+ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
21
+ // --- ENV SHIELD ---
22
+ const required = ['TEBI_ENDPOINT', 'TEBI_ACCESS_KEY_ID', 'TEBI_SECRET_ACCESS_KEY', 'TEBI_BUCKET'];
23
+ const missing = required.filter(k => !process.env[k]);
24
+ if (missing.length > 0) {
25
+ throw new Error(`Cấu hình Tebi.io chưa đầy đủ. Thiếu: ${missing.join(', ')}`);
26
+ }
27
+
28
+ if (!input || !slug) {
29
+ console.log('\nCách dùng:');
30
+ console.log(' node pipeline.mjs "URL_video" "slug" "Tên Phim" vi');
31
+ console.log(' node pipeline.mjs "D:\\film.mp4" "slug" "Tên Phim" vi');
32
+ console.log(' node pipeline.mjs "URL_video" "slug" "Tên Phim" both');
33
+ return;
34
+ }
35
+ console.log('\n╔══════════════════════════════════════════════════════════════════╗');
36
+ console.log(`║ 🎬 PIPELINE BẮT ĐẦU: ${slug}`);
37
+ console.log('╚══════════════════════════════════════════════════════════════════╝');
38
+
39
+ mkdirSync('downloads', { recursive: true });
40
+ mkdirSync(path.join('hls', slug), { recursive: true });
41
+
42
+ // ─── BƯỚC 1: Download ─────────────────────────────────────────
43
+ const mp4Path = existsSync(input) && input.toLowerCase().endsWith('.mp4')
44
+ ? input
45
+ : path.join('downloads', `${slug}.mp4`);
46
+
47
+ if (!existsSync(mp4Path)) {
48
+ console.log('\n🔽 [1/5] Downloading video...');
49
+
50
+ // Preserve previous yt-dlp improvements
51
+ let referer = '';
52
+ let userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
53
+
54
+ try {
55
+ const urlObj = new URL(input);
56
+ referer = `${urlObj.protocol}//${urlObj.host}/`;
57
+ } catch (_) { }
58
+
59
+ // Nếu là link S3/Tebi, dùng User-Agent sạch thay vì impersonate để tránh 403
60
+ 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
+
65
+ execSync(dlCmd, { stdio: 'inherit' });
66
+ console.log(' ✅ Download xong');
67
+ } else {
68
+ console.log(`\n⏭ [1/5] File đã có sẵn: ${mp4Path}`);
69
+ }
70
+
71
+ // ─── BƯỚC 2: Transcode HLS ────────────────────────────────────
72
+ const masterName = `txa-${slug}.m3u8`;
73
+ const masterPath = path.join('hls', slug, masterName);
74
+ const spritePath = path.join('hls', slug, 'preview.jpg');
75
+ const nokia3gpPath = path.join('hls', slug, `${slug}.3gp`);
76
+
77
+ // Danh sách toàn bộ chất lượng hỗ trợ
78
+ const ALL_QUALITIES = [
79
+ { label: '1080p', w: 1920, h: 1080, bv: '4500k', ba: '192k' },
80
+ { label: '720p', w: 1280, h: 720, bv: '1500k', ba: '128k' },
81
+ { label: '480p', w: 854, h: 480, bv: '1000k', ba: '128k' }, // Tăng bitrate cho 480p chút
82
+ { label: '360p', w: 640, h: 360, bv: '600k', ba: '96k' },
83
+ { label: '240p', w: 426, h: 240, bv: '400k', ba: '64k' },
84
+ { label: '144p', w: 256, h: 144, bv: '200k', ba: '48k' }
85
+ ];
86
+
87
+ // Lấy ngẫu nhiên từ 4 đến 6 chất lượng
88
+ const count = Math.floor(Math.random() * (Math.min(6, ALL_QUALITIES.length) - 4 + 1)) + 4;
89
+ const selectedQualities = [...ALL_QUALITIES]
90
+ .sort(() => Math.random() - 0.5)
91
+ .slice(0, count)
92
+ .sort((a, b) => b.h - a.h); // Sắp xếp cao -> thấp cho Master Playlist
93
+
94
+ if (!existsSync(masterPath)) {
95
+ console.log(`\n📦 [2/5] Transcoding HLS (${selectedQualities.length} qualities randomized)...`);
96
+ console.log(` ↳ Selected: ${selectedQualities.map(q => q.label).join(', ')}`);
97
+
98
+ const outDir = path.join('hls', slug);
99
+
100
+ // Xây dựng ffmpeg command động
101
+ let maps = '';
102
+ let outputs = '';
103
+ let varStreamMapArr = [];
104
+
105
+ selectedQualities.forEach((q, i) => {
106
+ maps += `-map 0:v -map 0:a `;
107
+ outputs += `-c:v:${i} libx264 -preset veryfast -b:v:${i} ${q.bv} -s:v:${i} ${q.w}x${q.h} -c:a:${i} aac -b:a:${i} ${q.ba} `;
108
+ varStreamMapArr.push(`v:${i},a:${i}`);
109
+ });
110
+
111
+ const ffmpegCmd = [
112
+ 'ffmpeg', '-i', `"${mp4Path}"`, '-threads 0',
113
+ maps,
114
+ outputs,
115
+ '-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
116
+ '-master_pl_name', `"${masterName}"`,
117
+ '-f hls -hls_time 6 -hls_list_size 0',
118
+ '-hls_segment_filename', `"${outDir}/%v/seg_%04d.ts"`,
119
+ `"${outDir}/%v/index.m3u8"`
120
+ ].join(' ');
121
+
122
+ execSync(ffmpegCmd, { stdio: 'inherit', shell: 'cmd.exe' });
123
+ console.log(' ✅ HLS Master generated.');
124
+
125
+ // --- BƯỚC 2.1: XUẤT 3GP CHO MÁY CỔ (NOKIA S40/S60) ---
126
+ console.log(' 📱 [2.1/5] Exporting 3GP legacy format...');
127
+ const nokiaCmd = `ffmpeg -i "${mp4Path}" -s 320x240 -vcodec mpeg4 -acodec aac -ar 16000 -ac 1 -b:v 250k -b:a 32k -f 3gp "${nokia3gpPath}"`;
128
+ try {
129
+ execSync(nokiaCmd, { stdio: 'ignore', shell: 'cmd.exe' });
130
+ console.log(' ✅ Legacy 3GP ready.');
131
+ } catch (e) {
132
+ console.log(' ⚠ 3GP generation failed.');
133
+ }
134
+ } else {
135
+ console.log(`\n⏭ [2/5] HLS đã có sẵn`);
136
+ }
137
+
138
+ // --- STEP 2.5: PREVIEW SPRITE --- (Independent check)
139
+ if (!existsSync(spritePath)) {
140
+ console.log(' 📸 [2.5/5] Generating Neon Preview SpriteSheet...');
141
+ const spriteCmd = `ffmpeg -i "${mp4Path}" -vf "fps=1/10,scale=160:90,tile=10x10" -q:v 3 "${spritePath}"`;
142
+ try {
143
+ execSync(spriteCmd, { stdio: 'ignore', shell: 'cmd.exe' });
144
+ console.log(' ✅ Preview SpriteSheet ready.');
145
+ } catch (e) { console.log(' ⚠ Preview generation failed.'); }
146
+ } else {
147
+ console.log(` ⏭ Preview SpriteSheet đã có sẵn`);
148
+ }
149
+
150
+ // ─── BƯỚC 3: Tạo README ───────────────────────────────────────
151
+ console.log('\n📄 [3/5] Generating README.txt...');
152
+ generateReadme(slug, mp4Path, { title });
153
+
154
+ // ─── BƯỚC 4: Tạo Subtitle (Python faster-whisper) ─────────────
155
+ console.log('\n📝 [4/5] Generating subtitles (faster-whisper offline)...');
156
+ const langs = langArg === 'both' ? ['vi', 'en'] : [langArg];
157
+
158
+ for (const lang of langs) {
159
+ const vttPath = path.join('hls', slug, `${lang}.vtt`);
160
+ if (!existsSync(vttPath)) {
161
+ // Detection logic for python command (python vs python3)
162
+ let pythonCmd = 'python';
163
+ try {
164
+ execSync('python --version', { stdio: 'ignore' });
165
+ } catch (e) {
166
+ pythonCmd = 'python3';
167
+ }
168
+
169
+ execSync(
170
+ `${pythonCmd} ${path.join('scripts', 'gen_subtitle.py')} "${mp4Path}" "${slug}" ${lang}`,
171
+ { stdio: 'inherit', cwd: process.cwd() }
172
+ );
173
+ } else {
174
+ console.log(` ⏭ Subtitle [${lang}] đã có sẵn`);
175
+ }
176
+ }
177
+
178
+ // ─── BƯỚC 5: Upload Tebi.io ─────────────────────────────────── // --- STEP 5: UPLOAD ---
179
+ console.log('\n🚀 [5/5] Deploying to Quantum Cloud (Tebi.io)...');
180
+ execSync(`node ${path.join('scripts', 'upload-tebi.mjs')} "${slug}"`, { stdio: 'inherit' });
181
+
182
+ // --- FINAL STEP: GENERATE MASTER README.md ---
183
+ const finalReadmePath = path.join('hls', slug, 'README.md');
184
+ const bucketName = process.env.TEBI_BUCKET || "txa-vod";
185
+ const cloudUrl = `https://s3.tebi.io/${bucketName}/${slug}/${masterName}`;
186
+ const nokiaUrl = `https://s3.tebi.io/${bucketName}/${slug}/${slug}.3gp`;
187
+
188
+ const report = `
189
+ # 🎬 TXA PIPELINE REPORT: ${title}
190
+ Generated on: ${new Date().toLocaleString()}
191
+
192
+ ## 📦 Asset Checklist
193
+ - [x] **Master Playlist:** \`${masterName}\` (AUTO Quality)
194
+ - [x] **Video Qualities:** ${selectedQualities.map(q => q.label).join(', ')} (Randomized)
195
+ - [${existsSync(nokia3gpPath) ? 'x' : ' '}] **Legacy Format:** \`${slug}.3gp\` (Nokia S40/S60)
196
+ - [${existsSync(spritePath) ? 'x' : ' '}] **Preview SpriteSheet:** \`preview.jpg\` (10x10 Grid)
197
+ - [${existsSync(path.join('hls', slug, 'vi.vtt')) ? 'x' : ' '}] **Vietnamese Subtitles:** AI Generated
198
+ - [${existsSync(path.join('hls', slug, 'en.vtt')) ? 'x' : ' '}] **English Subtitles:** AI Generated
199
+
200
+ ## 🔗 Final Streaming Link
201
+ > **[WATCH NOW (HLS)](${cloudUrl})**
202
+
203
+ ## 📱 Legacy Mobile Link (Nokia/Java)
204
+ > **[DOWNLOAD 3GP](${nokiaUrl})**
205
+
206
+ ---
207
+ *Generated by Antigravity AI Engine v2030*
208
+ `;
209
+
210
+ writeFileSync(finalReadmePath, report.trim());
211
+ console.log(`\n✨ PIPELINE COMPLETE: ${title}`);
212
+ console.log(`📄 Summary saved to: ${finalReadmePath}`);
213
+ console.log(`🔗 Cloud URL: ${cloudUrl}`);
214
+ if (existsSync(nokia3gpPath)) console.log(`📱 Nokia 3GP: ${nokiaUrl}`);
215
+
216
+ // --- AUTO CLEANUP ---
217
+ console.log('\n🧹 [CLEANUP] Purging local temporary files...');
218
+ try {
219
+ if (existsSync(mp4Path)) unlinkSync(mp4Path);
220
+ const hlsDir = path.join('hls', slug);
221
+ if (existsSync(hlsDir)) rmSync(hlsDir, { recursive: true, force: true });
222
+ console.log(' ✅ Downloads & HLS Temporary folder cleared.');
223
+ } catch (err) {
224
+ console.log(' ⚠ Cleanup failed, please delete manually.');
225
+ }
226
+
227
+ console.log('\n👉 Copy link ở trên → paste vào Admin Panel nrotxa.online');
228
+ console.log(`└ ▣ MISSION ACCOMPLISHED ✔\n`);
229
+ }
230
+
231
+ // Tự khởi chạy nếu được gọi trực tiếp qua CLI
232
+ if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) || process.argv[1].endsWith('pipeline.mjs')) {
233
+ run().catch(err => {
234
+ console.error('\n❌ LỖI:', err.message);
235
+ process.exit(1);
236
+ });
237
+ }
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NROTXA ULTIMATE CLI 2030 EDITION
4
+ * High-performance, Neon-infused Video Pipeline
5
+ */
6
+
7
+ import * as p from '@clack/prompts';
8
+ import chalk from 'chalk';
9
+ import figlet from 'figlet';
10
+ import ora from 'ora';
11
+ import { spawn } from 'child_process';
12
+ import { executePipeline } from './pipeline.mjs';
13
+ import 'dotenv/config';
14
+
15
+ // Color palette
16
+ const neonCyan = chalk.hex('#00f2ff');
17
+ const neonPurple = chalk.hex('#bc13fe');
18
+ const neonPink = chalk.hex('#ff00bd');
19
+ const electricBlue = chalk.hex('#2979ff');
20
+
21
+ function removeVietnameseTones(str) {
22
+ if (!str) return "";
23
+ return str.normalize("NFD")
24
+ .replace(/[\u0300-\u036f]/g, "")
25
+ .replace(/đ/g, "d").replace(/Đ/g, "D")
26
+ .replace(/[^a-zA-Z0-9\s-]/g, "")
27
+ .trim();
28
+ }
29
+
30
+ function checkEnv() {
31
+ const required = [
32
+ 'TEBI_ENDPOINT',
33
+ 'TEBI_ACCESS_KEY_ID',
34
+ 'TEBI_SECRET_ACCESS_KEY',
35
+ 'TEBI_BUCKET',
36
+ 'TEBI_PUBLIC_URL'
37
+ ];
38
+ const missing = required.filter(key => !process.env[key]);
39
+ if (missing.length > 0) {
40
+ console.log(chalk.red('\n❌ LỖI CẤU HÌNH:'));
41
+ console.log(chalk.yellow('Thiếu các biến môi trường sau trong file .env:'));
42
+ missing.forEach(m => console.log(` - ${m}`));
43
+ console.log(chalk.cyan('\nHướng dẫn:'));
44
+ console.log('1. Coppy file .env.example thành .env');
45
+ console.log('2. Điền đầy đủ thông tin tài khoản Tebi.io của bạn.');
46
+ process.exit(1);
47
+ }
48
+ }
49
+
50
+ function showHelp() {
51
+ console.clear();
52
+ console.log(
53
+ neonCyan(
54
+ figlet.textSync('TPHIM', {
55
+ font: 'Slant',
56
+ horizontalLayout: 'default',
57
+ verticalLayout: 'default',
58
+ })
59
+ )
60
+ );
61
+
62
+ console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v1.0 | HELP SYSTEM ⚡\n'));
63
+
64
+ console.log(`${neonPurple('📖 CÁCH SỬ DỤNG:')}`);
65
+ console.log(chalk.cyan(' ntxa help - Hiển thị help này'));
66
+ console.log(chalk.cyan(' ntxa - Chạy interactive mode'));
67
+ console.log(chalk.cyan(' ntxa "Tên Phim" - Chạy với tên phim mặc định\n'));
68
+
69
+ console.log(`${neonPurple('🔧 CẤU HÌNH BẮT BUỘC:')}`);
70
+ console.log(chalk.yellow(' File .env với các biến:'));
71
+ console.log(chalk.cyan(' TEBI_ENDPOINT - API endpoint Tebi.io'));
72
+ console.log(chalk.cyan(' TEBI_ACCESS_KEY_ID - Access key'));
73
+ console.log(chalk.cyan(' TEBI_SECRET_ACCESS_KEY - Secret key'));
74
+ console.log(chalk.cyan(' TEBI_BUCKET - Bucket name'));
75
+ console.log(chalk.cyan(' TEBI_PUBLIC_URL - Public URL\n'));
76
+
77
+ console.log(`${neonPurple('⚡ TÍNH NĂNG:')}`);
78
+ console.log(chalk.cyan(' • Download video từ multiple sources'));
79
+ console.log(chalk.cyan(' • Transcode sang HLS format'));
80
+ console.log(chalk.cyan(' • AI Subtitle generation (Whisper)'));
81
+ console.log(chalk.cyan(' • Upload cloud storage (Tebi.io)'));
82
+ console.log(chalk.cyan(' • Batch processing support'));
83
+ console.log(chalk.cyan(' • Multi-language subtitles (VI/EN)\n'));
84
+
85
+ console.log(`${neonPurple('👤 DEVELOPER:')}`);
86
+ console.log(chalk.cyan(' TXA - Ultimate Video Pipeline 2030'));
87
+ console.log(chalk.gray(' Licensed under ISC\n'));
88
+
89
+ console.log(`${neonPink('🚀 Ready to rock! Try: ntxa')}`);
90
+ }
91
+
92
+ async function main() {
93
+ const args = process.argv.slice(2);
94
+
95
+ // Check for help command
96
+ if (args.includes('help') || args.includes('--help') || args.includes('-h')) {
97
+ showHelp();
98
+ return;
99
+ }
100
+
101
+ checkEnv();
102
+ console.clear();
103
+
104
+ const argTitle = args.join(' ');
105
+
106
+ // Header
107
+ console.log(
108
+ neonCyan(
109
+ figlet.textSync('NROTXA 2030', {
110
+ font: 'Slant',
111
+ horizontalLayout: 'default',
112
+ verticalLayout: 'default',
113
+ })
114
+ )
115
+ );
116
+
117
+ console.log(chalk.gray(' ⚡ NEON-INFUSED VIDEO CORE v1.0 | STATUS: READY ⚡\n'));
118
+
119
+ p.intro(`${neonPurple('▣ SYSTEM INITIALIZED - BATCH MODE ENABLED')}`);
120
+
121
+ const group = await p.group(
122
+ {
123
+ input: () =>
124
+ p.text({
125
+ message: '🛸 Enter origin source(s):',
126
+ placeholder: 'link1, link2, link3...',
127
+ hint: chalk.yellow('Separate multiple links with COMMA (,)'),
128
+ validate: (value) => {
129
+ if (!value) return 'System requires a data source.';
130
+ },
131
+ }),
132
+ title: () =>
133
+ p.text({
134
+ message: chalk.cyan('🎬 Movie designation(s):'),
135
+ initialValue: argTitle,
136
+ placeholder: 'Title 1, Title 2... (Matching order of links)',
137
+ hint: 'Leave blank to auto-detect titles for each link',
138
+ }),
139
+ lang: () =>
140
+ p.select({
141
+ message: '🌐 Quantum transcription language:',
142
+ options: [
143
+ { value: 'vi', label: 'Tiếng Việt', hint: 'Default' },
144
+ { value: 'en', label: 'English', hint: 'Secondary' },
145
+ { value: 'both', label: 'Dual Core (VI+EN)', hint: 'Premium' },
146
+ ],
147
+ }),
148
+ confirm: () =>
149
+ p.confirm({
150
+ message: '🚀 Initiate batch pipeline sequence now?',
151
+ }),
152
+ },
153
+ {
154
+ onCancel: () => {
155
+ p.cancel('▣ SEQUENCE TERMINATED BY OPERATOR');
156
+ process.exit(0);
157
+ },
158
+ }
159
+ );
160
+
161
+ if (group.confirm) {
162
+ // Split inputs
163
+ const links = group.input.split(',').map(l => l.trim()).filter(l => l);
164
+ const titles = group.title ? group.title.split(',').map(t => t.trim()) : [];
165
+
166
+ console.log(chalk.cyan(`\n▣ DETECTED ${links.length} TARGETS. STARTING BATCH OPS...\n`));
167
+
168
+ for (let i = 0; i < links.length; i++) {
169
+ const inputUrl = links[i];
170
+ let currentTitle = titles[i] || '';
171
+
172
+ const s = p.spinner();
173
+ s.start(`${neonCyan(`[${i + 1}/${links.length}] PROCESSING:`)} ${inputUrl.substring(0, 40)}...`);
174
+
175
+ try {
176
+ // If title is missing, try to fetch it
177
+ if (!currentTitle) {
178
+ try {
179
+ s.message('🛰️ Fetching metadata...');
180
+ const { execSync } = await import('child_process');
181
+ // Use a lighter check first
182
+ let fetchedTitle = execSync(
183
+ `yt-dlp --print title --skip-download "${inputUrl}"`,
184
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
185
+ ).trim();
186
+
187
+ // Fallback: If title is 'master' or empty, use the URL slug
188
+ if (!fetchedTitle || fetchedTitle === 'master' || fetchedTitle === 'index') {
189
+ const parts = inputUrl.split('/').filter(p => p);
190
+ // Get second to last part if it looks like a slug
191
+ fetchedTitle = parts[parts.length - 2] || 'movie';
192
+ }
193
+
194
+ currentTitle = fetchedTitle;
195
+ } catch (e) {
196
+ currentTitle = `movie-${Date.now()}`;
197
+ }
198
+ }
199
+
200
+ const finalSlug = removeVietnameseTones(currentTitle).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
201
+
202
+ s.message(`${neonPurple('▣ EXECUTING PIPELINE:')} ${chalk.bold(currentTitle)}`);
203
+
204
+ await executePipeline({
205
+ input: inputUrl,
206
+ slug: finalSlug,
207
+ title: currentTitle,
208
+ langArg: group.lang
209
+ });
210
+
211
+ s.stop(`${neonPink(`✅ [${i + 1}/${links.length}] COMPLETE:`)} ${currentTitle}`);
212
+ } catch (err) {
213
+ s.stop(`${chalk.red(`❌ [${i + 1}/${links.length}] FAILED:`)} ${inputUrl}`);
214
+ console.error(chalk.red(err.message));
215
+ }
216
+ }
217
+
218
+ p.outro(`${neonPurple('▣ BATCH OPERATION FINISHED')} ${chalk.green('✔')}`);
219
+ } else {
220
+ p.cancel('▣ MISSION ABORTED');
221
+ }
222
+ }
223
+
224
+ main().catch(console.error);
@@ -0,0 +1,119 @@
1
+ // scripts/gen-readme.mjs
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execSync } from 'child_process';
5
+ import 'dotenv/config';
6
+
7
+ export function generateReadme(slug, mp4Path, info = {}) {
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
+ }
33
+
34
+ function getFolderSizeMB(dir) {
35
+ let total = 0;
36
+ if (!fs.existsSync(dir)) return '0 MB';
37
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
38
+ const full = path.join(dir, item.name);
39
+ if (item.isDirectory()) total += parseFloat(getFolderSizeMB(full));
40
+ else total += fs.statSync(full).size / (1024 * 1024);
41
+ }
42
+ return total.toFixed(1) + ' MB';
43
+ }
44
+
45
+ function getStreamInfo(filePath) {
46
+ try {
47
+ const json = JSON.parse(execSync(
48
+ `ffprobe -v quiet -print_format json -show_streams "${filePath}"`,
49
+ { stdio: ['pipe', 'pipe', 'pipe'] }).toString());
50
+ const video = json.streams.find(s => s.codec_type === 'video');
51
+ const audio = json.streams.find(s => s.codec_type === 'audio');
52
+ return {
53
+ resolution: video ? `${video.width}x${video.height}` : 'N/A',
54
+ videoCodec: video ? video.codec_name.toUpperCase() : 'N/A',
55
+ audioCodec: audio ? audio.codec_name.toUpperCase() : 'N/A',
56
+ fps: video ? Math.round(eval(video.r_frame_rate)) + ' fps' : 'N/A',
57
+ };
58
+ } catch { return { resolution: 'N/A', videoCodec: 'N/A', audioCodec: 'N/A', fps: 'N/A' }; }
59
+ }
60
+
61
+ const duration = getVideoDuration(mp4Path);
62
+ const totalSize = getFolderSizeMB(hlsDir);
63
+ const tsCount = countFiles(hlsDir, '.ts');
64
+ const hasSub = fs.existsSync(path.join(hlsDir, 'vi.vtt'));
65
+ const hasSubEn = fs.existsSync(path.join(hlsDir, 'en.vtt'));
66
+ const streamInfo = getStreamInfo(mp4Path);
67
+ const now = new Date().toLocaleString('vi-VN', { timeZone: 'Asia/Ho_Chi_Minh' });
68
+ const publicUrl = process.env.TEBI_PUBLIC_URL || 'https://s3.tebi.io/nrotxa-videos';
69
+
70
+ const readme =
71
+ `================================================================================
72
+ THÔNG TIN FOLDER: hls/${slug}
73
+ Tạo lúc: ${now}
74
+ ================================================================================
75
+
76
+ 📽️ PHIM
77
+ Tên : ${info.title || slug}
78
+ Slug : ${slug}
79
+ Thời lượng : ${duration}
80
+
81
+ 📦 FILE GỐC
82
+ File MP4 : ${path.basename(mp4Path)}
83
+ Resolution : ${streamInfo.resolution}
84
+ Video Codec : ${streamInfo.videoCodec}
85
+ Audio Codec : ${streamInfo.audioCodec}
86
+ FPS : ${streamInfo.fps}
87
+
88
+ 📂 HLS OUTPUT (folder này)
89
+ Tổng dung lượng : ${totalSize}
90
+ Số segment .ts : ${tsCount} file
91
+ Segment length : ~6 giây/file
92
+
93
+ Chất lượng được tạo:
94
+ [1080p] 1920x1080 · Video 4500kbps · Audio 192kbps → folder 0\\
95
+ [720p] 1280x720 · Video 1500kbps · Audio 128kbps → folder 1\\
96
+ [360p] 640x360 · Video 600kbps · Audio 96kbps → folder 2\\
97
+
98
+ Master playlist : hls/${slug}/master.m3u8
99
+ → Đây là link chính truyền vào TXAPlayer
100
+
101
+ 📝 SUBTITLE
102
+ Tiếng Việt : ${hasSub ? 'hls/' + slug + '/vi.vtt ✅' : '❌ Chưa có'}
103
+ Tiếng Anh : ${hasSubEn ? 'hls/' + slug + '/en.vtt ✅' : '❌ Chưa có'}
104
+ Tạo bởi : faster-whisper model small (offline, CPU)
105
+
106
+ ☁️ TEBI.IO (sau khi upload)
107
+ HLS URL : ${publicUrl}/hls/${slug}/master.m3u8
108
+ VTT (VI) : ${publicUrl}/hls/${slug}/vi.vtt
109
+
110
+ ================================================================================
111
+ ⚠️ Có thể xóa folder này sau khi upload lên Tebi.io thành công
112
+ ================================================================================
113
+ `;
114
+
115
+ const readmePath = path.join(hlsDir, 'README.txt');
116
+ fs.writeFileSync(readmePath, readme, 'utf8');
117
+ console.log(` ✅ README.txt saved: ${readmePath}`);
118
+ return readmePath;
119
+ }
@@ -0,0 +1,89 @@
1
+ # scripts/gen_subtitle.py
2
+ # Usage: python gen_subtitle.py <video_path> <slug> [vi|en]
3
+ # Dùng faster-whisper chạy offline hoàn toàn, không cần API
4
+
5
+ import sys
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+ from faster_whisper import WhisperModel
10
+
11
+ def extract_audio(video_path, audio_path):
12
+ """Trích audio nhẹ từ video để whisper xử lý nhanh hơn."""
13
+ print(" ↳ Extracting audio...")
14
+ subprocess.run([
15
+ "ffmpeg", "-i", video_path,
16
+ "-vn", "-acodec", "pcm_s16le",
17
+ "-ar", "16000", "-ac", "1",
18
+ audio_path, "-y"
19
+ ], capture_output=True, check=True)
20
+
21
+ def transcribe(audio_path, lang="vi"):
22
+ """Chạy faster-whisper model small — phù hợp máy 8GB RAM CPU."""
23
+ print(f" ↳ Loading whisper model 'small' (CPU mode)...")
24
+ print(f" ↳ Lần đầu sẽ tải model ~460MB, sau đó cache lại.")
25
+
26
+ # model small: ~1.5GB RAM, đủ cho máy 8GB
27
+ # compute_type int8: tối ưu cho CPU, giảm RAM
28
+ model = WhisperModel("small", device="cpu", compute_type="int8")
29
+
30
+ print(f" ↳ Transcribing [{lang}]... (CPU: mất 20-40 phút/phim dài)")
31
+ segments, info = model.transcribe(
32
+ audio_path,
33
+ language=lang,
34
+ beam_size=3, # giảm từ 5 → 3 để tiết kiệm RAM
35
+ vad_filter=True, # bỏ đoạn im lặng, nhanh hơn
36
+ vad_parameters=dict(min_silence_duration_ms=500)
37
+ )
38
+
39
+ return list(segments)
40
+
41
+ def segments_to_vtt(segments):
42
+ """Chuyển segments sang định dạng WebVTT."""
43
+ vtt = "WEBVTT\n\n"
44
+ for i, seg in enumerate(segments, 1):
45
+ vtt += f"{i}\n"
46
+ vtt += f"{to_vtt_time(seg.start)} --> {to_vtt_time(seg.end)}\n"
47
+ vtt += f"{seg.text.strip()}\n\n"
48
+ return vtt
49
+
50
+ def to_vtt_time(seconds):
51
+ h = int(seconds // 3600)
52
+ m = int((seconds % 3600) // 60)
53
+ s = seconds % 60
54
+ return f"{h:02d}:{m:02d}:{s:06.3f}"
55
+
56
+ def main():
57
+ if len(sys.argv) < 3:
58
+ print("Usage: python gen_subtitle.py <video_path> <slug> [vi|en]")
59
+ sys.exit(1)
60
+
61
+ video_path = sys.argv[1]
62
+ slug = sys.argv[2]
63
+ lang = sys.argv[3] if len(sys.argv) > 3 else "vi"
64
+
65
+ out_dir = Path("hls") / slug
66
+ out_dir.mkdir(parents=True, exist_ok=True)
67
+
68
+ audio_path = f"temp_audio_{slug}.wav"
69
+
70
+ print(f"\n📝 Generating subtitle [{lang}] for: {slug}")
71
+
72
+ try:
73
+ extract_audio(video_path, audio_path)
74
+ segments = transcribe(audio_path, lang)
75
+ vtt = segments_to_vtt(segments)
76
+
77
+ vtt_path = out_dir / f"{lang}.vtt"
78
+ vtt_path.write_text(vtt, encoding="utf-8")
79
+
80
+ print(f" ✅ Saved: {vtt_path}")
81
+ print(f" ✅ Total segments: {len(segments)}")
82
+
83
+ finally:
84
+ # Xóa file audio tạm dù có lỗi hay không
85
+ if os.path.exists(audio_path):
86
+ os.remove(audio_path)
87
+
88
+ if __name__ == "__main__":
89
+ main()
@@ -0,0 +1,88 @@
1
+ // scripts/upload-tebi.mjs
2
+ // Tebi.io S3-compatible — dùng @aws-sdk/client-s3 bình thường
3
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
4
+ import { readdirSync, readFileSync } from 'fs';
5
+ import { join, extname, relative } from 'path';
6
+ import { lookup } from 'mime-types';
7
+ import 'dotenv/config';
8
+
9
+ const s3 = new S3Client({
10
+ region: 'us-east-1', // Tebi yêu cầu region này dù server ở EU
11
+ endpoint: process.env.TEBI_ENDPOINT, // https://s3.tebi.io
12
+ credentials: {
13
+ accessKeyId: process.env.TEBI_ACCESS_KEY_ID,
14
+ secretAccessKey: process.env.TEBI_SECRET_ACCESS_KEY,
15
+ },
16
+ forcePathStyle: true, // Tebi cần pathStyle, không dùng subdomain
17
+ });
18
+
19
+ export async function uploadHLSFolder(slug) {
20
+ const localDir = join('hls', slug);
21
+ const files = getAllFiles(localDir);
22
+
23
+ console.log(`\n☁️ Uploading ${files.length} files to Tebi.io...`);
24
+ let uploaded = 0;
25
+
26
+ for (const filePath of files) {
27
+ const s3Key = relative('.', filePath).replace(/\\/g, '/');
28
+ const ext = extname(filePath).toLowerCase();
29
+
30
+ const cacheMap = {
31
+ '.ts': 'public, max-age=31536000',
32
+ '.m3u8': 'public, max-age=300',
33
+ '.vtt': 'public, max-age=86400',
34
+ '.txt': 'no-cache',
35
+ };
36
+
37
+ await s3.send(new PutObjectCommand({
38
+ Bucket: process.env.TEBI_BUCKET,
39
+ Key: s3Key,
40
+ Body: readFileSync(filePath),
41
+ ACL: 'public-read', // Mở khóa file công khai ngay khi upload
42
+ ContentType: lookup(filePath) || 'application/octet-stream',
43
+ CacheControl: cacheMap[ext] || 'public, max-age=3600',
44
+ }));
45
+
46
+ uploaded++;
47
+ process.stdout.write(`\r ↳ ${uploaded}/${files.length} files...`);
48
+ }
49
+
50
+ const base = process.env.TEBI_PUBLIC_URL;
51
+ const masterName = `txa-${slug}.m3u8`;
52
+ const finalMasterUrl = `${base}/hls/${slug}/${masterName}`;
53
+
54
+ const result = {
55
+ masterUrl: finalMasterUrl,
56
+ vttVi: `${base}/hls/${slug}/vi.vtt`,
57
+ vttEn: `${base}/hls/${slug}/en.vtt`,
58
+ };
59
+
60
+ console.log(`\n`);
61
+ console.log(`╔══════════════════════════════════════════════════════════════════╗`);
62
+ console.log(`║ ✅ UPLOAD HOÀN TẤT — Copy 2 link này vào Admin Panel ║`);
63
+ console.log(`╠══════════════════════════════════════════════════════════════════╣`);
64
+ console.log(`║ HLS : ${result.masterUrl}`);
65
+ console.log(`║ VTT : ${result.vttVi}`);
66
+ console.log(`╚══════════════════════════════════════════════════════════════════╝`);
67
+
68
+ return result;
69
+ }
70
+
71
+ function getAllFiles(dir) {
72
+ const result = [];
73
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
74
+ const full = join(dir, item.name);
75
+ if (item.isDirectory()) result.push(...getAllFiles(full));
76
+ else result.push(full);
77
+ }
78
+ return result;
79
+ }
80
+ // Tự chạy nếu gọi trực tiếp
81
+ if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) || process.argv[1].endsWith('upload-tebi.mjs')) {
82
+ const slug = process.argv[2];
83
+ if (slug) {
84
+ uploadHLSFolder(slug).catch(console.error);
85
+ } else {
86
+ console.log("Usage: node upload-tebi.mjs <slug>");
87
+ }
88
+ }