CoolFace
Apppublic

ur5lgq/iptv-transcoder

sourceHugging Faceupdated 1mo agoView on Hugging Face
0likes
app.py901 linesDownload Raw Back to root
1import os2import sys3import json4import time5import psutil6import glob7import subprocess8import threading9import time10 11from flask import Flask, request, Response, render_template_string, jsonify, send_from_directory, redirect12 13app = Flask(__name__)14 15 16# Настройки путей (локально для Hugging Face)17BASE_DIR = os.path.dirname(os.path.abspath(__file__))18PLAYLIST_FILE = os.path.join(BASE_DIR, 'playlist.m3u')19CURRENT_URL_FILE = os.path.join(BASE_DIR, 'current_stream.txt')20ORIGINAL_NAME_FILE = os.path.join(BASE_DIR, 'playlist_name.txt')21CURRENT_NAME_FILE = os.path.join(BASE_DIR, 'current_name.txt')22CONFIG_FILE = os.path.join(BASE_DIR, 'config.json')23 24HLS_DIR = BASE_DIR # Сегменты пишем в текущую папку25 26# Глобальная переменная для хранения запущенного процесса FFmpeg27ffmpeg_process = None28 29from github_storage import (30    get_config, save_config,31    load_playlist, save_playlist,32    save_current_channel, load_current_channel,33    load_file, save_file34)35 36 37# --- ИСПРАВЛЕННЫЙ МОНИТОРИНГ CPU ДЛЯ HUGGING FACE (cgroups v2) ---38current_container_cpu = 0.039 40def _get_cgroup_v2_usage():41    """Считывает процессорное время для cgroups v2 из cpu.stat"""42    try:43        with open('/sys/fs/cgroup/cpu.stat', 'r') as f:44            for line in f:45                if line.startswith('usage_usec'):46                    # Переводим микросекунды в наносекунды для совместимости со старыми расчетами47                    return int(line.split()[1]) * 100048    except Exception:49        return None50 51def _cpu_monitor_worker():52    """Фоновый поток, поддерживающий cgroups v2, v1 и локальный psutil"""53    global current_container_cpu54    import os55 56    # Определяем доступное количество ядер для контейнера (обычно 2 или 4 на HF)57    try:58        num_cores = os.cpu_count() or 159    except Exception:60        num_cores = 161 62    # Пробуем инициализировать начальное значение по cgroups v2 или v163    last_usage = _get_cgroup_v2_usage()64    is_v2 = True65    66    if last_usage is None:67        is_v2 = False68        try:69            with open('/sys/fs/cgroup/cpu/cpuacct.usage', 'r') as f:70                last_usage = int(f.read())71        except Exception:72            last_usage = None73 74    last_time = time.time()75 76    while True:77        time.sleep(1.0)  # Интервал опроса ровно 1 секунда78        now_time = time.time()79        time_delta = now_time - last_time80        if time_delta <= 0:81            continue82        83        try:84            # Считываем текущие показатели в зависимости от версии cgroups85            if is_v2:86                usage = _get_cgroup_v2_usage()87            else:88                with open('/sys/fs/cgroup/cpu/cpuacct.usage', 'r') as f:89                    usage = int(f.read())90            91            if usage is not None and last_usage is not None:92                # Расчет процента утилизации с учетом всех ядер контейнера93                cpu_percent = ((usage - last_usage) / (time_delta * 1000000000.0)) * 100.094                cpu_percent = cpu_percent / num_cores95                current_container_cpu = max(0.0, min(100.0, round(cpu_percent, 2)))96                last_usage = usage97            else:98                raise Exception("Кастомный cgroups недоступен")99                100        except Exception:101            # Резервный вариант для Windows/Локального запуска, где нет cgroups102            import psutil103            current_container_cpu = round(psutil.cpu_percent(), 2)104            105        last_time = now_time106 107# Автоматический перезапуск монитора в фоне108monitor_thread = threading.Thread(target=_cpu_monitor_worker, daemon=True)109monitor_thread.start()110 111 112# 1. API: Получение загрузки CPU (Обновленный роут)113@app.route('/get_cpu')114def get_cpu():115    return f"{current_container_cpu * 10:.1f}"116 117 118 119# 2. API: Количество TS сегментов120@app.route('/ts_count')121def ts_count():122    return str(len(glob.glob(os.path.join(HLS_DIR, '*.ts'))))123    124# Добавляем этот роут:125@app.route('/hls.js')126def redirect_hls():127    from flask import redirect # импорт на месте для надежности128    return redirect("https://cdn.jsdelivr.net/npm/hls.js@latest")129 130@app.route('/playlist.m3u')131def get_compressed_playlist():132    # Импортируем нужные функции чтения плейлиста133    from github_storage import load_playlist134    import re135    from flask import request, Response136 137    raw_content = load_playlist()138    if not raw_content or raw_content.strip() == "#EXTM3U":139        return Response("База каналов пуста или не загружена.", status=503, mimetype="text/plain")140 141    # Получаем адрес текущего сервера, чтобы приставка знала, куда слать запросы142    host = request.host143 144    # Парсим исходный плейлист регулярным выражением (как в коде примера)145    pattern = re.compile(r"#EXTINF.*?,(.*?)\n(http[s]?://.*?)(?=\n#EXTINF|\n|$)", re.DOTALL)146    matches = pattern.findall(raw_content)147 148    m3u_content = "#EXTM3U\n"149 150    # Генерируем новые ссылки для приставки, пропуская их через наш транскодер151    for idx, (name, ch_url) in enumerate(matches, start=1):152        # Формируем ссылку на поток нашего сервера Flask153        # Приставка будет запрашивать поток по ID канала154        stream_url = f"http://{host}/stream/{idx}.ts"155        156        # Очищаем имя от лишних переносов строк и экранируем ломающие кавычки157        display_name = name.strip().replace("\r", "")158        safe_name = display_name.replace('"', "'")159 160        # Формируем расширенный заголовок для корректного отображения в VLC/плеерах161        m3u_content += (162            f'#EXTINF:-1 tvg-name="{safe_name}" artist="Transcoder" title="{safe_name}",{safe_name}\n'163            f'{stream_url}\n'164        )165 166    return Response(m3u_content, mimetype="audio/x-mpegurl")167 168import subprocess169from flask import Response170 171@app.route('/stream/<int:ch_id>.ts')172def stream_channel_direct(ch_id):173    from github_storage import load_playlist174    import re175    import time176 177    print(f"\n[STATION LOG] >>> Получен запрос от приставки на канал ID: {ch_id}")178 179    raw_content = load_playlist()180    if not raw_content:181        print(f"[STATION LOG] !!! Ошибка: плейлист пуст или не загружен с GitHub.")182        return Response("Плейлист пуст", status=404, mimetype="text/plain")183 184    # Парсим плейлист, чтобы найти исходную URL-ссылку канала по его номеру (ID)185    pattern = re.compile(r"#EXTINF.*?,(.*?)\n(http[s]?://.*?)(?=\n#EXTINF|\n|$)", re.DOTALL)186    matches = pattern.findall(raw_content)187 188    # Проверяем, существует ли канал с таким ID189    if ch_id < 1 or ch_id > len(matches):190        print(f"[STATION LOG] !!! Ошибка: Канал с ID {ch_id} отсутствует в плейлисте (Всего каналов: {len(matches)})")191        return Response("Канал не найден", status=404, mimetype="text/plain")192 193    clean_name, target_url = matches[ch_id - 1]194    clean_name = clean_name.strip().replace("\r", "")195    target_url = target_url.strip()196 197    # ИСПРАВЛЕНО: Вызываем функцию get_config() напрямую, как она определена в вашем app.py198    config = get_config() or {}199    profile = config.get("server_profile", "360")200 201    # Выставляем настройки под выбранное качество202    pad_filter = ""203    if profile == '720': 204        scale, bitrate, crf_val = '1280:720', '2500k', '20'205    elif profile == '480': 206        scale, bitrate, crf_val = '854:480', '1000k', '21'207    elif profile == '360_in_480': 208        scale, bitrate, crf_val = '640:360', '800k', '21'209        pad_filter = ",pad=854:480:(ow-iw)/2:(oh-ih)/2:black"210    elif profile == '270_in_360': 211        scale, bitrate, crf_val = '480:270', '400k', '22'212        pad_filter = ",pad=640:360:(ow-iw)/2:(oh-ih)/2:black"213    else: 214        scale, bitrate, crf_val = '640:360', '500k', '22'215 216    print(f"[STATION LOG] СТАРТ: Канал '{clean_name}'")217    print(f"[STATION LOG] Профиль: {profile} (Scale: {scale}, Bitrate: {bitrate})")218    print(f"[STATION LOG] Исходный URL: {target_url[:60]}...")219 220    # Собираем команду FFmpeg для потоковой трансляции в pipe (mpegts)221    ffmpeg_cmd = [222        "ffmpeg",223        "-hide_banner",224        "-loglevel", "error", 225        "-user_agent", "Lavf/61.7.103",226        "-i", target_url,227        "-vf", f"scale={scale}{pad_filter}",228        "-c:v", "libx264",229        "-preset", "ultrafast",  230        "-tune", "zerolatency",231        "-crf", crf_val,232        "-b:v", bitrate,233        "-c:a", "aac",234        "-b:a", "64k",235        "-f", "mpegts",          236        "pipe:1"                 237    ]238 239    def generate_stream():240        start_time = time.time()241        total_bytes = 0242        243        # Запускаем процесс FFmpeg244        process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)245        print(f"[STATION LOG] [PID {process.pid}] Процесс FFmpeg успешно запущен.")246        247        try:248            while True:249                chunk = process.stdout.read(65536)250                if not chunk:251                    print(f"[STATION LOG] [PID {process.pid}] Поток закончился (источник закрыл соединение).")252                    break253                total_bytes += len(chunk)254                yield chunk255        except GeneratorExit:256            print(f"[STATION LOG] [PID {process.pid}] Приставка отключилась или переключила канал.")257            process.kill()258        finally:259            process.wait()260            duration = time.time() - start_time261            mb_sent = total_bytes / (1024 * 1024)262            print(f"[STATION LOG] СТОП: Трансляция '{clean_name}' завершена.")263            print(f"[STATION LOG] Итог: Время просмотра: {duration:.1f} сек. Передано данных: {mb_sent:.2f} МБ.\n")264 265    return Response(generate_stream(), mimetype="video/mp2t")  266    267 268# 2. Запросы на медиа-файлы (low.m3u8, seg_1.ts и т.д.) обрабатываются только здесь!269from flask import abort270 271@app.route('/<path:filename>')272def serve_hls_stream_files(filename):273 274    # запрещаем скрытые файлы275    if filename.startswith("."):276        abort(404)277 278    # запрещаем python279    if filename.endswith(".py"):280        abort(404)281 282    # запрещаем git283    if filename.startswith(".git"):284        abort(404)285 286    # запрещаем env287    if filename.endswith(".env"):288        abort(404)289 290    # запрещаем конфиги291    if filename in (292        "config.json",293        "requirements.txt",294        "github_storage.py",295    ):296        abort(404)297 298    # разрешаем только HLS299    if filename == "low.m3u8" or filename.endswith(".ts"):300        return send_from_directory(HLS_DIR, filename)301 302    abort(404)303    304# Главная страница + Обработка форм управления305@app.route('/', methods=['GET', 'POST'])306def index():307    global ffmpeg_process308 309    # Динамически определяем статус стрима по состоянию процесса FFmpeg310    stream_status = (311        ffmpeg_process is not None and312        ffmpeg_process.poll() is None313    )314 315    # Стабильное считывание конфигурации316    config = get_config() or {}317    318    # Загружаем список избранных из GitHub319    from github_storage import load_favorites, save_favorites320    favorites = load_favorites()321 322    if request.method == 'POST':323        action = request.form.get('action')324 325        # ОБРАБОТКА ИЗБРАННОГО: Добавить326        if action == 'add_favorite':327            fav_name = request.form.get('fav_name')328            fav_url = request.form.get('fav_url')329            if fav_url and not any(f['url'] == fav_url for f in favorites):330                favorites.append({"name": fav_name, "url": fav_url})331                save_favorites(favorites)332 333        # ОБРАБОТКА ИЗБРАННОГО: Удалить334        elif action == 'remove_favorite':335            fav_url = request.form.get('fav_url')336            favorites = [f for f in favorites if f['url'] != fav_url]337            save_favorites(favorites)338 339        # 1.1. ОБРАБОТКА ЗАГРУЗКИ ПЛЕЙЛИСТА ПО ССЫЛКЕ340        elif action == 'playlist_by_url':341            playlist_url = request.form.get('playlist_url')342            if playlist_url and playlist_url.startswith('http'):343                try:344                    import requests345                    # Скачиваем плейлист с таймаутом 10 секунд346                    response = requests.get(playlist_url, timeout=10)347                    if response.status_code == 200:348                        content = response.text349                        save_playlist(content)350                        351                        # Извлекаем имя файла из ссылки или ставим стандартное352                        downloaded_name = playlist_url.split('/')[-1].split('?')[0]353                        if not downloaded_name.endswith('.m3u'):354                            downloaded_name = 'remote_playlist.m3u'355                            356                        save_file('playlist_name.txt', downloaded_name)357                        print(f"Плейлист из сети {downloaded_name} успешно загружен")358                except Exception as e:359                    print(f"Ошибка при скачивании плейлиста: {e}")360 361        362        # 1. ОБРАБОТКА ЗАГРУЗКИ ПЛЕЙЛИСТА (.m3u)363        elif 'playlist_upload' in request.files:364            file = request.files['playlist_upload']365            if file and file.filename.endswith('.m3u'):366                content = file.read().decode('utf-8', errors='ignore')367                save_playlist(content)368                save_file('playlist_name.txt', file.filename)369                print(f"Плейлист {file.filename} успешно загружен на GitHub")370 371        # 2. СОХРАНЕНИЕ НАСТРОЕК372        elif 'save_settings' in request.form:373            cfg = {374                "server_profile": request.form.get("server_profile", "360"),375                "stream_mode": request.form.get("stream_mode", "transcode")376            }377            save_config(cfg)378            config = cfg379            print("Конфигурация успешно сохранена на GitHub")380 381        # 3. Обработка действий СТАРТ / СТОП382        elif action in ['start', 'stop']:383            if ffmpeg_process:384                print("Останавливаем старый процесс FFmpeg...")385                ffmpeg_process.kill()386                try:387                    ffmpeg_process.wait(timeout=5)388                except subprocess.TimeoutExpired:389                    print("Процесс не завершился по таймауту")390                except:391                    pass392                ffmpeg_process = None393 394            for f in glob.glob(os.path.join(HLS_DIR, '*.ts')) + glob.glob(os.path.join(HLS_DIR, 'low.m3u8')):395                try: os.remove(f)396                except: pass397 398            if action == 'start':399                stream_url = request.form.get('stream_url')400                stream_name = request.form.get('stream_name', 'Канал')401                final_url = stream_url402                time.sleep(1)403 404                if final_url:405                    print("Запускаем поток:", final_url)406                    save_current_channel(stream_name, stream_url)407 408                    mode = config.get('stream_mode', 'transcode')409                    profile = config.get('server_profile', '360')410 411                    if mode == "direct":412                        with open(os.path.join(HLS_DIR, 'low.m3u8'), 'w') as f:413                            f.write(f"#EXTM3U\n#EXTINF:-1,Stream\n{final_url}")414                            415                    else:416                        # Настраиваем scale, pad, bitrate, bufsize и CRF под каждый профиль индивидуально417                        # По умолчанию pad_filter пустой (простое масштабирование)418                        pad_filter = ""419 420                        if profile == '720': 421                            scale, bitrate, bufsize, crf_val = '1280:720', '2500k', '5000k', '20'422                        elif profile == '480': 423                            scale, bitrate, bufsize, crf_val = '854:480', '1000k', '2000k', '21'424                        elif profile == '360_in_480': 425                            # 360p (640x360) в черном квадрате 480p (854x480)426                            scale, bitrate, bufsize, crf_val = '640:360', '800k', '1600k', '21'427                            pad_filter = ",pad=854:480:(ow-iw)/2:(oh-ih)/2:black"428                        elif profile == '270_in_360': 429                            # 270p (480x270) в черном квадрате 360p (640x360)430                            scale, bitrate, bufsize, crf_val = '480:270', '400k', '800k', '22'431                            pad_filter = ",pad=640:360:(ow-iw)/2:(oh-ih)/2:black"432                        else: 433                            scale, bitrate, bufsize, crf_val = '640:360', '500k', '1000k', '22'434 435                        # Объединяем масштабирование и центрирование в черном квадрате436                        vf_argument = f"scale={scale}{pad_filter}"437                        438                        ffmpeg_cmd = [439                            "ffmpeg",440                            "-hide_banner",441                            "-loglevel", "debug",442 443                            "-user_agent", "Lavf/61.7.103",444 445                            "-i", final_url,446 447                            "-vf", vf_argument,  # Используем объединенный фильтр видео448 449                            "-c:v", "libx264",450                            "-preset", "veryfast",451                            "-tune", "zerolatency",452                            "-crf", crf_val,453 454                            "-c:a", "aac",455                            "-b:a", "64k",456 457                            "-f", "hls",458                            "-hls_time", "4",459                            "-hls_list_size", "5",460                            "-hls_flags", "delete_segments",461                            "-hls_segment_filename", os.path.join(HLS_DIR, "seg_%d.ts"),462 463                            os.path.join(HLS_DIR, "low.m3u8")464                        ]465                        ffmpeg_process = subprocess.Popen(ffmpeg_cmd)466                        stream_status = True467 468 469        elif action == 'stop':470            save_current_channel("-", "-")471            if os.path.exists(CURRENT_NAME_FILE): os.remove(CURRENT_NAME_FILE)472            if os.path.exists(CURRENT_URL_FILE): os.remove(CURRENT_URL_FILE)473            stream_status = False474            # Жёстко заставляем программу забыть старый URL прямо в текущем запросе475            current_name = "-"476            current_url = "-"477 478    # Сбор данных для рендеринга страницы (с защитой от задержек GitHub)479    current_data = load_current_channel()480    481    # Если мы только что нажали СТОП (переменная action определена в этом запросе) — принудительно сбрасываем данные482    if request.method == 'POST' and request.form.get('action') == 'stop':483        current_name = "-"484        current_url = "-"485    else:486        current_name = current_data.get("name", "-")487        current_url = current_data.get("url", "-")488    489    # Канал считается запущенным на сервере, если в GitHub записан URL и это не прочерк490    is_running = current_url not in ["", "-", None, "—"]491    playlist_name = load_file("playlist_name.txt", default="Не загружен")492    ts_count_val = len(glob.glob(os.path.join(HLS_DIR, '*.ts')))493 494    # Проверяем, жива ли фоновая задача трансляции FFmpeg495    is_ffmpeg_alive = ffmpeg_process is not None and ffmpeg_process.poll() is None496 497    # ГЕНЕРАЦИЯ HTML ДЛЯ СПИСКА КАНАЛОВ M3U498    playlist_content = load_playlist()499    channels_html = ""500    fav_urls = [f['url'] for f in favorites]501    502    if playlist_content and playlist_content != "#EXTM3U\n":503        lines = playlist_content.splitlines()504        current_channel_name = "Канал"505        for line in lines:506            line = line.strip()507            if line.startswith('#EXTINF'):508                parts = line.split(',')509                current_channel_name = parts[-1].strip() if len(parts) > 0 else "Канал"510            elif line.startswith('http'):511                is_fav = line in fav_urls512                fav_action = 'remove_favorite' if is_fav else 'add_favorite'513                fav_btn = f"<button type='submit' style='background:#d62828;'>❌</button>" if is_fav else f"<button type='submit' style='background:#f77f00;'>★</button>"514                515                channels_html += f"""516                <div class='item' data-fav='{"true" if is_fav else "false"}'>517                    <span>{current_channel_name}</span>518                    <div style='display:flex; gap:5px;'>519                        <form method='POST' style='margin:0;'>520                            <input type='hidden' name='action' value='{fav_action}'>521                            <input type='hidden' name='fav_name' value='{current_channel_name}'>522                            <input type='hidden' name='fav_url' value='{line}'>523                            {fav_btn}524                        </form>525                        <form method='POST' style='margin:0;'>526                            <input type='hidden' name='action' value='start'>527                            <input type='hidden' name='stream_url' value='{line}'>528                            <input type='hidden' name='stream_name' value='{current_channel_name}'>529                            <button type='submit'>СТАРТ</button>530                        </form>531                    </div>532                </div>"""533 534    # Вычисляем состояние трехцветного круглого индикатора и текстового статуса для Python-рендера535    if not is_running:536        status_dot_class = 'stopped'  # Серый круг537        status_text = 'ОСТАНОВЛЕНО'538    else:539        if ts_count_val > 0 and is_ffmpeg_alive:540            status_dot_class = 'online'   # Зеленый круг541            status_text = 'В ЭФИРЕ'542        else:543            if ffmpeg_process is not None:544                status_dot_class = 'online'545                status_text = 'ПОДКЛЮЧЕНИЕ...'546            else:547                status_dot_class = 'offline'  # Красный круг548                status_text = 'НЕТ ДОСТУПА'549 550    # Настройка кнопки Выключить/Выключено в зависимости от статуса551    if is_running:552        btn_text = "ВЫКЛЮЧИТЬ"553        btn_style = "background:#ba181b; cursor:pointer;"554        btn_disabled = ""555    else:556        btn_text = "ВЫКЛЮЧЕНО"557        btn_style = "background:#555; cursor:not-allowed; opacity:0.6;"558        btn_disabled = "disabled"559 560    # Блок плеера с тремя раздельными индикаторами561    player_html = f"""562    <div class="info-block">563        <video id="video" controls autoplay muted playsinline style="margin-top:5px; width:100%; background:#000; border-radius:4px;"></video>564        565        <!-- Ряд 1: Индикаторы (ЦП, Status, TS) слева, Кнопка управления справа -->566        <div style="display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 10px;">567            <div style="display: flex; align-items: center; gap: 6px; flex-grow: 1;">568                569                <div class="status-badge" style="height: 38px; box-sizing: border-box; display: flex; align-items: center; white-space: nowrap; gap: 10px;">570                    <span id="indicator-dot" class="status-dot {status_dot_class}"></span>571                    <span id="indicator-text" style="font-size: 12px; font-weight: bold;">{status_text}</span>572                    <span style="opacity: 0.5; font-size: 11px;">|</span>573                    <span style="font-size: 12px; color: #40916c;"><span id="ts-count">{ts_count_val}</span> TS</span>574                </div>575                576                <div class="status-badge" style="height: 38px; box-sizing: border-box; display: flex; align-items: center; white-space: nowrap; font-weight: bold;">577                    CPU: <span id="cpu-val" style="margin-left: 4px;">...</span>578                </div>579            </div>580            581            <form method="POST" style="margin: 0; flex-shrink: 0; width: 140px;" onsubmit="handleStopClick();">582                <input type="hidden" name="action" value="stop">583                <button type="submit" id="stop-btn" {btn_disabled} style="{btn_style} width:100%; padding: 10px;">{btn_text}</button>584            </form>585        </div>586 587        <div style="display: flex; flex-direction: column; gap: 3px; margin-top: 10px; border-top: 1px solid #333; padding-top: 10px;">588            <div style="font-size: 13px;"><b>Канал:</b> <span id="info-channel-name">{current_name}</span></div>589            <div style="word-break: break-all; opacity: 0.6; font-size: 10px;"><b>URL:</b> <span id="info-channel-url">{current_url}</span></div>590        </div>591    </div>592    """593 594    # Стабильно получаем текущий профиль и режим для отображения в HTML595    current_profile = config.get("server_profile", "360")596    current_mode = config.get("stream_mode", "transcode")597 598    # Подготовка флагов selected для выпадающего списка качества599    p_720 = 'selected' if current_profile == '720' else ''600    p_480 = 'selected' if current_profile == '480' else ''601    p_360_in_480 = 'selected' if current_profile == '360_in_480' else ''602    p_360 = 'selected' if current_profile == '360' else ''603    p_270_in_360 = 'selected' if current_profile == '270_in_360' else ''604 605    # Подготовка флагов selected для режима работы606    m_trans = 'selected' if current_mode == 'transcode' else ''607    m_direct = 'selected' if current_mode == 'direct' else ''608    609 610    HTML = f"""611        <!DOCTYPE html>612        <html lang="ru">613        <head>614        <meta charset="UTF-8">615        <title>IPTV Control</title>616        <script src="/hls.js"></script>617        <style>618        body {{ 619            font-family: sans-serif; 620            margin: 0; 621            padding: 10px; 622            background: #121212; 623            color: #e0e0e0; 624        }}625        626        .app-container {{627            width: 100%;628            max-width: 800px;629            margin: 0 auto;630        }}631 632        .sticky-panel {{ 633            background: #121212;634            padding-bottom: 5px;635        }}636 637        .header-panel {{ background: #1e1e1e; padding: 15px; border-radius: 8px; border-left: 5px solid #40916c; margin-bottom: 15px; }}638        .status-badge {{display: flex; align-items: center; gap: 8px; padding: 4px 10px; border-radius: 4px; font-weight: bold; background: #252525; border: 1px solid #333; font-size: 13px;}}639        640        .status-dot {{width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex-shrink: 0;}}641        .status-dot.stopped {{background: #777777; box-shadow: none;}} 642        .status-dot.online {{background: #00ff00; box-shadow: 0 0 8px #00ff00;}} 643        .status-dot.offline {{background: #ff0000; box-shadow: 0 0 8px #ff0000;}} 644        645        .info-block {{ font-size: 12px; margin: 10px 0; padding: 15px; background: #1e1e1e; border-radius: 8px; border: 1px solid #333; }}646        647        .search-container {{ display: flex; gap: 10px; margin-bottom: 10px; }}648        .search-box {{ flex-grow: 1; padding: 12px; border-radius: 5px; border: none; background: #333; color: white; }}649        button {{ cursor: pointer; padding: 8px 15px; font-weight: bold; border: none; border-radius: 5px; color : #fff; background: #40916c; }}650        .nav-btn.active {{ background: #40916c; }}651        .nav-btn {{ background: #444; }}652 653        .settings-toggle-btn {{654            background: #1e1e1e;655            border: 1px solid #333;656            width: 100%;657            text-align: left;658            padding: 12px;659            margin-bottom: 15px;660            border-radius: 8px;661            font-size: 14px;662            display: flex;663            justify-content: space-between;664            align-items: center;665            color: #fff;666            font-weight: bold;667        }}668        .settings-toggle-btn::after {{ content: '▼'; font-size: 10px; transition: transform 0.2s; }}669        .settings-toggle-btn.active::after {{ transform: rotate(180deg); }}670        .settings-content {{671            display: none;672            background: #1e1e1e; 673            padding: 15px; 674            border-radius: 8px; 675            margin-bottom: 15px;676            border: 1px solid #333;677        }}678        .settings-content.show {{ display: block; }}679 680        .scroll-content {{681            padding-top: 5px;682        }}683 684        .item {{ background: #1e1e1e; padding: 12px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; border-radius: 4px; margin-bottom: 5px; }}685        .item:last-child {{ margin-bottom: 0; }}686        </style>687        </head>688        <body>689        690        <div class="app-container">691            <div class="sticky-panel">692                693                {player_html}694 695                <button id="toggle-settings-btn" class="settings-toggle-btn">⚙️ Настройки и Загрузка плейлиста</button>696 697                <div id="settings-block" class="settings-content">698                    <div style="font-size:13px; opacity:0.9; border-bottom: 1px solid #333; padding-bottom: 12px; margin-bottom: 12px;">699                        <b>Текущий плейлист:</b> <span style="color:#40916c;">{playlist_name}</span>700                        <!-- Форма для загрузки файлом -->701                        <form method="POST" enctype="multipart/form-data" style="margin-top:8px; display: flex; gap: 8px;">702                            <input type="file" name="playlist_upload" accept=".m3u" style="flex-grow:1; background:#252525; padding:5px; border-radius:4px; color:#fff; border:1px solid #333;"> 703                            <button type="submit" style="background: #555">Файл M3U</button>704                        </form>705 706                        <!-- Новая форма для загрузки по ссылке -->707                        <form method="POST" style="margin-top:8px; display: flex; gap: 8px;">708                            <input type="hidden" name="action" value="playlist_by_url">709                            <input type="url" name="playlist_url" placeholder="Вставьте ссылку http://... .m3u" required style="flex-grow:1; background:#252525; padding:8px; border-radius:4px; color:#fff; border:1px solid #333; font-size:12px;"> 710                            <button type="submit" style="background: #40916c">Загрузить URL</button>711                        </form>712                    </div>713 714                    <form method="POST" style="display: flex; flex-direction: column; gap: 12px;">715                        <div style="display: flex; gap: 15px; align-items: center;">716                            <label style="font-size:13px;"><b>Качество потока:</b></label>717                            <select name="server_profile" style="padding: 8px; background: #333; color: white; border: none; border-radius: 4px; flex-grow: 1;">718                                <option value="720" {p_720}>720p (HD)</option>719                                <option value="480" {p_480}>480p (HQ)</option>720                                <option value="360_in_480" {p_360_in_480}>360p в квадрате 480p</option>721                                <option value="360" {p_360}>360p (LQ)</option>722                                <option value="270_in_360" {p_270_in_360}>270p в квадрате 360p</option>723                            </select>724                        </div>725                        <div style="display: flex; gap: 15px; align-items: center;">726                            <label style="font-size:13px;"><b>Режим работы:</b></label>727                            <select name="stream_mode" style="padding: 8px; background: #333; color: white; border: none; border-radius: 4px; flex-grow: 1;">728                                <option value="transcode" {m_trans}>Транскодирование</option>729                                <option value="direct" {m_direct}>Прямой поток</option>730                            </select>731                        </div>732                        <button type="submit" name="save_settings" value="1" style="width: 100%; padding: 10px; margin-top: 5px;">Сохранить параметры сервера</button>733                    </form>734                </div>735 736                <div class="search-container">737                    <button id="show-all-btn" class="nav-btn active">Все M3U</button>738                    <button id="show-fav-btn" class="nav-btn">★ Избранное</button>739                    <input type="text" id="search" class="search-box" placeholder="Фильтр по названию каналов...">740                </div>741            </div>742            743            <div class="scroll-content">744                <div id="list">745                    {channels_html}746                </div>747            </div>748        </div>749 750        <script>751        const searchInput = document.getElementById('search');752        const allBtn = document.getElementById('show-all-btn');753        const favBtn = document.getElementById('show-fav-btn');754        const toggleSettingsBtn = document.getElementById('toggle-settings-btn');755        const settingsBlock = document.getElementById('settings-block');756        let currentMode = 'all'; 757 758        toggleSettingsBtn.addEventListener('click', () => {{759            settingsBlock.classList.toggle('show');760            toggleSettingsBtn.classList.toggle('active');761        }});762 763        function updateCPU() {{764            fetch('/get_cpu').then(r => r.text()).then(v => {{765                const el = document.getElementById('cpu-val');766                if (el) el.innerText = v + '%';767            }});768        }}769        setInterval(updateCPU, 2000); updateCPU();770 771        // Функция мгновенной очистки интерфейса при нажатии на кнопку выключения (За экранирована для f-строки)772        function handleStopClick() {{773            const dot = document.getElementById('indicator-dot');774            const txt = document.getElementById('indicator-text');775            const btn = document.getElementById('stop-btn');776            const tsEl = document.getElementById('ts-count');777            const chName = document.getElementById('info-channel-name');778            const chUrl = document.getElementById('info-channel-url');779            const video = document.getElementById('video');780 781            // Гасим плеер782            if (video) {{ video.pause(); video.src = ""; }}783 784            // Перекрашиваем кнопку в серую "ВЫКЛЮЧЕНО"785            if (btn) {{786                btn.innerText = "ВЫКЛЮЧЕНО";787                btn.setAttribute('disabled', 'true');788                btn.style.cssText = "background:#555; cursor:not-allowed; opacity:0.6; width:100%; padding: 10px;";789            }}790 791            // Сбрасываем индикаторы в "ОСТАНОВЛЕНО"792            if (dot) {{ dot.className = 'status-dot stopped'; }}793            if (txt) {{ txt.innerText = 'ОСТАНОВЛЕНО'; }}794            if (tsEl) {{ tsEl.innerText = '0'; }}795 796            // Стираем название канала и его адрес797            if (chName) {{ chName.innerText = '-'; }}798            if (chUrl) {{ chUrl.innerText = '-'; }}799        }}800 801        function updateTS() {{802            const el = document.getElementById('ts-count'); 803            const dot = document.getElementById('indicator-dot');804            const txt = document.getElementById('indicator-text');805            const stopBtn = document.getElementById('stop-btn');806            const chName = document.getElementById('info-channel-name');807            const chUrl = document.getElementById('info-channel-url');808            809            if (el) {{ 810                fetch('/ts_count').then(r => r.text()).then(v => {{811                    let count = parseInt(v) || 0;812                    813                    // Если кнопка заблокирована на клиенте — игнорируем ответы сервера о файлах814                    if (stopBtn && stopBtn.hasAttribute('disabled')) {{815                        el.innerText = '0';816                        if(dot) {{ dot.className = 'status-dot stopped'; }}817                        if(txt) {{ txt.innerText = 'ОСТАНОВЛЕНО'; }}818                        if(chName) {{ chName.innerText = '-'; }}819                        if(chUrl) {{ chUrl.innerText = '-'; }}820                    }} else {{821                        el.innerText = count;822                        if (count > 0) {{823                            if(dot) {{ dot.className = 'status-dot online'; }}824                            if(txt) {{ txt.innerText = 'В ЭФИРЕ'; }}825                        }} else {{826                            if(txt && txt.innerText !== 'ПОДКЛЮЧЕНИЕ...') {{827                                if(dot) {{ dot.className = 'status-dot offline'; }}828                                if(txt) {{ txt.innerText = 'НЕТ ДОСТУПА'; }}829                            }}830                        }}831                    }}832                }}); 833            }}834        }}835        setInterval(updateTS, 2000); updateTS();836 837        function filterChannels() {{838            let term = searchInput.value.toLowerCase();839            document.querySelectorAll('.item').forEach(item => {{840                let matchesSearch = item.innerText.toLowerCase().includes(term);841                let isFav = item.getAttribute('data-fav') === 'true';842                843                if (currentMode === 'fav' && !isFav) {{844                    item.style.display = 'none';845                }} else {{846                    item.style.display = matchesSearch ? 'flex' : 'none';847                }}848            }});849        }}850 851        allBtn.addEventListener('click', () => {{852            currentMode = 'all';853            allBtn.classList.add('active');854            favBtn.classList.remove('active');855            filterChannels();856        }});857 858        favBtn.addEventListener('click', () => {{859            currentMode = 'fav';860            favBtn.classList.add('active');861            allBtn.classList.remove('active');862            filterChannels();863        }});864 865        searchInput.addEventListener('input', filterChannels);866 867        var video = document.getElementById('video');868        if (video && window.Hls && Hls.isSupported()) {{869            var hls = new Hls();870            var isLoaded = false;871            function checkSegmentsAndPlay() {{872                if (isLoaded) return;873                fetch('/ts_count')874                    .then(response => response.text())875                    .then(count => {{876                        let tsCount = parseInt(count) || 0;877                        if (tsCount >= 2) {{878                            isLoaded = true;879                            hls.loadSource('low.m3u8?t=' + Date.now());880                            hls.attachMedia(video);881                            video.play().catch(e => {{ console.log("Автозапуск заблокирован браузером"); }});882                            clearInterval(waitInterval);883                        }}884                    }});885            }}886            if (video) {{ var waitInterval = setInterval(checkSegmentsAndPlay, 1000); checkSegmentsAndPlay(); }}887        }}888        </script>889        </body>890        </html>891    """892    return render_template_string(HTML)893 894if __name__ == '__main__':895    print("APP STARTED")896    from github_storage import get_config897    print("CONFIG =", get_config())898    print("TOKEN =", bool(os.getenv("GITHUB_TOKEN")))899    print("USER =", os.getenv("GITHUB_USER"))900    print("REPO =", os.getenv("GITHUB_REPO"))901    app.run(host='0.0.0.0', port=7860, debug=False)