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 # 50MB max file size 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", # top_left, top_right, bottom_left, bottom_right, center "ad_width": 250, "mode": "periodic", # always, periodic, schedule "period_interval": 300, # every 300 seconds (5 mins) "period_duration": 30, # show for 30 seconds } 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: # Check if file is gif or static image (png/jpg) 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) # format=rgba preserves transparent background for PNGs filter_complex_parts.append(f"[1:v]format=rgba,scale={width}:-1:flags=fast_bilinear[ad_scaled];") pos = config.get("ad_position", "top_right") # Coordinate mapping for overlay 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 = """ Antv Live - IPTV Restream & Visual Ad Manager

ANTV Live Restream & Visual Ad Manager

Hệ thống chèn logo/PNG trong suốt & Quảng cáo động trực quan

System Online
{% if message %}
{{ message }}
{% endif %}

Cấu hình Luồng & Tùy chọn Chèn

{% if config.ad_file %}

Đang dùng file: {{ config.ad_file }}

{% endif %}

Xem Trước Trực Tiếp (Live Stream)

Master Playlist M3U8:

/hls/master.m3u8
""" @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/') def serve_hls(filename): return send_from_directory('/app/hls', filename) if __name__ == '__main__': app.run(host='0.0.0.0', port=7860)