| import os |
| import sys |
| import json |
| import time |
| import threading |
| import subprocess |
| from flask import Flask, render_template_string, request, redirect, url_for, jsonify, send_from_directory |
| from werkzeug.utils import secure_filename |
|
|
| app = Flask(__name__) |
| app.config['UPLOAD_FOLDER'] = '/app/uploads' |
| app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 |
| os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) |
| os.makedirs('/app/hls', exist_ok=True) |
|
|
| CONFIG_FILE = '/app/stream_config.json' |
|
|
| DEFAULT_CONFIG = { |
| "input_stream": "http://10k.lucastv.pro/2c99755ae255/gJnMAT2/465825", |
| "channel_name": "Antv Live Stream", |
| "ad_enabled": False, |
| "ad_file": "", |
| "ad_position": "top_right", |
| "ad_width": 250, |
| "mode": "periodic", |
| "period_interval": 300, |
| "period_duration": 30, |
| } |
|
|
| def load_config(): |
| if os.path.exists(CONFIG_FILE): |
| try: |
| with open(CONFIG_FILE, 'r') as f: |
| return {**DEFAULT_CONFIG, **json.load(f)} |
| except: |
| pass |
| return DEFAULT_CONFIG |
|
|
| def save_config(config): |
| with open(CONFIG_FILE, 'w') as f: |
| json.dump(config, f, indent=4) |
|
|
| ffmpeg_process = None |
| process_lock = threading.Lock() |
|
|
| def build_ffmpeg_cmd(config): |
| input_stream = config.get("input_stream", DEFAULT_CONFIG["input_stream"]) |
| output_dir = "/app/hls" |
| |
| cmd = [ |
| "ffmpeg", "-y", "-re", |
| "-fflags", "+genpts+igndts+discardcorrupt", |
| "-probesize", "4000000", |
| "-analyzeduration", "4000000", |
| "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", |
| "-i", input_stream |
| ] |
| |
| filter_complex_parts = [] |
| |
| ad_file = config.get("ad_file", "") |
| ad_path = os.path.join(app.config['UPLOAD_FOLDER'], ad_file) if ad_file else "" |
| has_ad = config.get("ad_enabled", False) and ad_file and os.path.exists(ad_path) |
| |
| if has_ad: |
| |
| is_gif = ad_file.lower().endswith('.gif') |
| if is_gif: |
| cmd.extend(["-stream_loop", "-1", "-i", ad_path]) |
| else: |
| cmd.extend(["-i", ad_path]) |
| |
| width = config.get("ad_width", 250) |
| |
| filter_complex_parts.append(f"[1:v]format=rgba,scale={width}:-1:flags=fast_bilinear[ad_scaled];") |
| |
| pos = config.get("ad_position", "top_right") |
| |
| if pos == "top_left": |
| x, y = "30", "30" |
| elif pos == "top_right": |
| x, y = "main_w-overlay_w-30", "30" |
| elif pos == "bottom_left": |
| x, y = "30", "main_h-overlay_h-30" |
| elif pos == "bottom_right": |
| x, y = "main_w-overlay_w-30", "main_h-overlay_h-30" |
| elif pos == "center": |
| x, y = "(main_w-overlay_w)/2", "(main_h-overlay_h)/2" |
| else: |
| x, y = "main_w-overlay_w-30", "30" |
| |
| mode = config.get("mode", "periodic") |
| if mode == "always": |
| enable_expr = "1" |
| elif mode == "periodic": |
| interval = config.get("period_interval", 300) |
| duration = config.get("period_duration", 30) |
| enable_expr = f"lt(mod(t\\,{interval}),{duration})" |
| else: |
| enable_expr = "1" |
| |
| filter_complex_parts.append( |
| f"[0:v][ad_scaled]overlay={x}:{y}:enable='{enable_expr}',format=yuv420p,split=4[v1][v2][v3][v4];" |
| ) |
| else: |
| filter_complex_parts.append( |
| "[0:v]format=yuv420p,split=4[v1][v2][v3][v4];" |
| ) |
| |
| filter_complex_parts.extend([ |
| "[v1]scale=1280:720,fps=30[v720p30];", |
| "[v2]scale=1280:720,fps=50[v720p50];", |
| "[v3]scale=1920:1080,fps=30[v1080p30];", |
| "[v4]scale=1920:1080,fps=50[v1080p50];", |
| "[0:a]aresample=44100:async=1,pan=stereo|" |
| "FL=0.5*FL+0.707*FC+0.5*BL+0.5*LFE|" |
| "FR=0.5*FR+0.707*FC+0.5*BR+0.5*LFE,asplit=4[a1][a2][a3][a4]" |
| ]) |
| |
| cmd.extend([ |
| "-filter_complex", "".join(filter_complex_parts), |
| "-map", "[v720p30]", "-map", "[a1]", "-c:v:0", "libx264", "-preset", "ultrafast", "-b:v:0", "1200k", "-maxrate:v:0", "1500k", "-bufsize:v:0", "3000k", "-g:v:0", "60", "-keyint_min:v:0", "60", "-sc_threshold:v:0", "0", "-c:a:0", "aac", "-b:a:0", "128k", |
| "-map", "[v720p50]", "-map", "[a2]", "-c:v:1", "libx264", "-preset", "ultrafast", "-b:v:1", "1800k", "-maxrate:v:1", "2200k", "-bufsize:v:1", "4500k", "-g:v:1", "100", "-keyint_min:v:1", "100", "-sc_threshold:v:1", "0", "-c:a:1", "aac", "-b:a:1", "128k", |
| "-map", "[v1080p30]", "-map", "[a3]", "-c:v:2", "libx264", "-preset", "ultrafast", "-b:v:2", "2500k", "-maxrate:v:2", "3000k", "-bufsize:v:2", "6000k", "-g:v:2", "60", "-keyint_min:v:2", "60", "-sc_threshold:v:2", "0", "-c:a:2", "aac", "-b:a:2", "192k", |
| "-map", "[v1080p50]", "-map", "[a4]", "-c:v:3", "libx264", "-preset", "ultrafast", "-b:v:3", "3500k", "-maxrate:v:3", "4000k", "-bufsize:v:3", "8000k", "-g:v:3", "100", "-keyint_min:v:3", "100", "-sc_threshold:v:3", "0", "-c:a:3", "aac", "-b:a:3", "192k", |
| "-f", "hls", |
| "-hls_time", "4", |
| "-start_number", "0", |
| "-hls_list_size", "6", |
| "-hls_flags", "delete_segments+independent_segments", |
| "-master_pl_name", "master.m3u8", |
| "-var_stream_map", "v:0,a:0,name:720p30 v:1,a:1,name:720p50 v:2,a:2,name:1080p30 v:3,a:3,name:1080p50", |
| f"{output_dir}/%v/segment_%05d.ts", |
| f"{output_dir}/%v/playlist.m3u8" |
| ]) |
| |
| for sub in ["720p30", "720p50", "1080p30", "1080p50"]: |
| os.makedirs(os.path.join(output_dir, sub), exist_ok=True) |
| |
| return cmd |
|
|
| def run_ffmpeg_worker(): |
| global ffmpeg_process |
| while True: |
| config = load_config() |
| cmd = build_ffmpeg_cmd(config) |
| print("[FFmpeg Manager] Starting FFmpeg process...") |
| |
| with process_lock: |
| ffmpeg_process = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr) |
| |
| ffmpeg_process.wait() |
| print("[FFmpeg Manager] FFmpeg exited. Restarting in 3 seconds...") |
| time.sleep(3) |
|
|
| threading.Thread(target=run_ffmpeg_worker, daemon=True).start() |
|
|
| def restart_ffmpeg(): |
| global ffmpeg_process |
| with process_lock: |
| if ffmpeg_process and ffmpeg_process.poll() is None: |
| print("[FFmpeg Manager] Terminating current FFmpeg for config update...") |
| ffmpeg_process.terminate() |
| try: |
| ffmpeg_process.wait(timeout=5) |
| except: |
| ffmpeg_process.kill() |
|
|
| HTML_TEMPLATE = """ |
| <!DOCTYPE html> |
| <html lang="vi"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Antv Live - IPTV Restream & Visual Ad Manager</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> |
| <style> |
| .pos-btn { transition: all 0.2s; } |
| .pos-btn.active { background-color: #06b6d4; color: white; border-color: #22d3ee; box-shadow: 0 0 15px rgba(6,182,212,0.5); } |
| </style> |
| </head> |
| <body class="bg-slate-900 text-slate-100 min-h-screen"> |
| <div class="container mx-auto px-4 py-8 max-w-6xl"> |
| <header class="flex justify-between items-center mb-8 border-b border-slate-700 pb-4"> |
| <div> |
| <h1 class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-pink-500"> |
| ANTV Live Restream & Visual Ad Manager |
| </h1> |
| <p class="text-slate-400 text-sm mt-1">Hệ thống chèn logo/PNG trong suốt & Quảng cáo động trực quan</p> |
| </div> |
| <div class="flex items-center gap-2"> |
| <span class="inline-block w-3 h-3 bg-emerald-500 rounded-full animate-pulse"></span> |
| <span class="text-emerald-400 font-medium text-sm">System Online</span> |
| </div> |
| </header> |
| |
| {% if message %} |
| <div class="bg-emerald-900/50 border border-emerald-500 text-emerald-200 px-4 py-3 rounded-lg mb-6 flex justify-between items-center"> |
| <span>{{ message }}</span> |
| <button onclick="this.parentElement.remove()" class="text-emerald-400 hover:text-white font-bold">×</button> |
| </div> |
| {% endif %} |
| |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-8"> |
| <!-- Left Panel: Form Settings --> |
| <div class="lg:col-span-7 bg-slate-800/80 border border-slate-700 rounded-2xl p-6 shadow-xl space-y-6"> |
| <h2 class="text-xl font-semibold flex items-center gap-2 text-cyan-400"> |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg> |
| Cấu hình Luồng & Tùy chọn Chèn |
| </h2> |
| |
| <form method="POST" enctype="multipart/form-data" class="space-y-5"> |
| <div> |
| <label class="block text-sm font-medium text-slate-300 mb-1">Link IPTV Nguồn (Input Stream URL)</label> |
| <input type="text" name="input_stream" value="{{ config.input_stream }}" required |
| class="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-2 text-slate-200 focus:outline-none focus:border-cyan-500"> |
| </div> |
| |
| <div class="border-t border-slate-700 pt-4"> |
| <div class="flex items-center justify-between mb-4"> |
| <label class="font-medium text-slate-200 flex items-center gap-2 cursor-pointer"> |
| <input type="checkbox" name="ad_enabled" {% if config.ad_enabled %}checked{% endif %} class="w-4 h-4 accent-cyan-500 rounded"> |
| Bật Chèn File (PNG trong suốt / GIF động / MP4) |
| </label> |
| </div> |
| |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4"> |
| <div> |
| <label class="block text-sm font-medium text-slate-300 mb-1">Tải lên hình ảnh / GIF / MP4</label> |
| <input type="file" name="ad_file_upload" accept=".png,.gif,.mp4,.jpg" |
| class="w-full text-sm text-slate-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-cyan-600 file:text-white hover:file:bg-cyan-500"> |
| {% if config.ad_file %} |
| <p class="text-xs text-slate-400 mt-1">Đang dùng file: <span class="text-cyan-400">{{ config.ad_file }}</span></p> |
| {% endif %} |
| </div> |
| <div> |
| <label class="block text-sm font-medium text-slate-300 mb-1">Chiều rộng hiển thị (px)</label> |
| <input type="number" name="ad_width" value="{{ config.ad_width }}" class="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-2 text-slate-200 focus:outline-none focus:border-cyan-500"> |
| </div> |
| </div> |
| |
| <!-- VISUAL POSITION SELECTOR --> |
| <div class="mb-4"> |
| <label class="block text-sm font-medium text-slate-300 mb-2">Chọn vị trí hiển thị trực quan trên màn hình</label> |
| <input type="hidden" name="ad_position" id="ad_position_input" value="{{ config.ad_position }}"> |
| |
| <div class="relative w-full aspect-video bg-slate-950 border-2 border-slate-700 rounded-xl overflow-hidden p-3 flex flex-col justify-between shadow-inner"> |
| <!-- Top Row --> |
| <div class="flex justify-between"> |
| <button type="button" data-pos="top_left" class="pos-btn px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-xs font-semibold text-slate-300 hover:border-cyan-400">↖ Góc Trên Trái</button> |
| <button type="button" data-pos="top_right" class="pos-btn px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-xs font-semibold text-slate-300 hover:border-cyan-400">↗ Góc Trên Phải</button> |
| </div> |
| <!-- Center Row --> |
| <div class="flex justify-center"> |
| <button type="button" data-pos="center" class="pos-btn px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-xs font-semibold text-slate-300 hover:border-cyan-400">⊙ Chính Giữa</button> |
| </div> |
| <!-- Bottom Row --> |
| <div class="flex justify-between"> |
| <button type="button" data-pos="bottom_left" class="pos-btn px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-xs font-semibold text-slate-300 hover:border-cyan-400">↙ Góc Dưới Trái</button> |
| <button type="button" data-pos="bottom_right" class="pos-btn px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-xs font-semibold text-slate-300 hover:border-cyan-400">↘ Góc Dưới Phải</button> |
| </div> |
| </div> |
| </div> |
| |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> |
| <div> |
| <label class="block text-sm font-medium text-slate-300 mb-1">Chế độ hiển thị</label> |
| <select name="mode" id="mode_select" onchange="toggleModeFields()" class="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-slate-200 focus:outline-none focus:border-cyan-500"> |
| <option value="always" {% if config.mode == 'always' %}selected{% endif %}>Luôn luôn hiển thị</option> |
| <option value="periodic" {% if config.mode == 'periodic' %}selected{% endif %}>Định kỳ (Theo chu kỳ giây)</option> |
| </select> |
| </div> |
| </div> |
| |
| <div id="periodic_fields" class="grid grid-cols-2 gap-4 mt-4 bg-slate-900/50 p-4 rounded-xl border border-slate-700/50"> |
| <div> |
| <label class="block text-xs font-medium text-slate-300 mb-1">Chu kỳ lặp lại (giây)</label> |
| <input type="number" name="period_interval" value="{{ config.period_interval }}" class="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-1.5 text-slate-200 text-sm"> |
| </div> |
| <div> |
| <label class="block text-xs font-medium text-slate-300 mb-1">Thời gian hiển thị (giây)</label> |
| <input type="number" name="period_duration" value="{{ config.period_duration }}" class="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-1.5 text-slate-200 text-sm"> |
| </div> |
| </div> |
| </div> |
| |
| <div class="pt-4 flex justify-end"> |
| <button type="submit" class="bg-gradient-to-r from-cyan-500 to-pink-500 text-white font-semibold px-6 py-2.5 rounded-xl shadow-lg hover:opacity-90 transition transform active:scale-95"> |
| Lưu Cấu Hình & Áp Dụng Ngay |
| </button> |
| </div> |
| </form> |
| </div> |
| |
| <!-- Right Panel: Live Player Preview --> |
| <div class="lg:col-span-5 space-y-6"> |
| <div class="bg-slate-800/80 border border-slate-700 rounded-2xl p-6 shadow-xl"> |
| <h2 class="text-xl font-semibold mb-4 flex items-center gap-2 text-pink-400"> |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg> |
| Xem Trước Trực Tiếp (Live Stream) |
| </h2> |
| <div class="aspect-video bg-black rounded-xl overflow-hidden relative shadow-inner"> |
| <video id="video" controls autoplay muted class="w-full h-full object-contain"></video> |
| </div> |
| <div class="mt-4 text-xs text-slate-400 space-y-1"> |
| <p><strong>Master Playlist M3U8:</strong></p> |
| <code class="block bg-slate-900 p-2 rounded text-cyan-300 break-all select-all">/hls/master.m3u8</code> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <script> |
| // Visual Position Selector Logic |
| const currentPos = "{{ config.ad_position }}"; |
| const posButtons = document.querySelectorAll('.pos-btn'); |
| const posInput = document.getElementById('ad_position_input'); |
| |
| function updateActiveButton(pos) { |
| posButtons.forEach(btn => { |
| if (btn.getAttribute('data-pos') === pos) { |
| btn.classList.add('active'); |
| } else { |
| btn.classList.remove('active'); |
| } |
| }); |
| posInput.value = pos; |
| } |
| |
| posButtons.forEach(btn => { |
| btn.addEventListener('click', () => { |
| const pos = btn.getAttribute('data-pos'); |
| updateActiveButton(pos); |
| }); |
| }); |
| |
| updateActiveButton(currentPos); |
| |
| function toggleModeFields() { |
| const mode = document.getElementById('mode_select').value; |
| const periodicFields = document.getElementById('periodic_fields'); |
| if (mode === 'periodic') { |
| periodicFields.style.display = 'grid'; |
| } else { |
| periodicFields.style.display = 'none'; |
| } |
| } |
| toggleModeFields(); |
| |
| // HLS Player init |
| var video = document.getElementById('video'); |
| var videoSrc = '/hls/master.m3u8'; |
| if (Hls.isSupported()) { |
| var hls = new Hls(); |
| hls.loadSource(videoSrc); |
| hls.attachMedia(video); |
| } else if (video.canPlayType('application/vnd.apple.mpegurl')) { |
| video.src = videoSrc; |
| } |
| </script> |
| </body> |
| </html> |
| """ |
|
|
| @app.route('/', methods=['GET', 'POST']) |
| def index(): |
| config = load_config() |
| message = None |
| |
| if request.method == 'POST': |
| config['input_stream'] = request.form.get('input_stream', config['input_stream']) |
| config['ad_enabled'] = True if request.form.get('ad_enabled') else False |
| config['ad_position'] = request.form.get('ad_position', 'top_right') |
| try: |
| config['ad_width'] = int(request.form.get('ad_width', 250)) |
| except: |
| pass |
| config['mode'] = request.form.get('mode', 'periodic') |
| try: |
| config['period_interval'] = int(request.form.get('period_interval', 300)) |
| config['period_duration'] = int(request.form.get('period_duration', 30)) |
| except: |
| pass |
| |
| file = request.files.get('ad_file_upload') |
| if file and file.filename != '': |
| filename = secure_filename(file.filename) |
| filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) |
| file.save(filepath) |
| config['ad_file'] = filename |
| |
| save_config(config) |
| restart_ffmpeg() |
| message = "Đã lưu cấu hình và áp dụng thay đổi thành công!" |
| config = load_config() |
| |
| return render_template_string(HTML_TEMPLATE, config=config, message=message) |
|
|
| @app.route('/hls/<path:filename>') |
| def serve_hls(filename): |
| return send_from_directory('/app/hls', filename) |
|
|
| if __name__ == '__main__': |
| app.run(host='0.0.0.0', port=7860) |
|
|