ur5lgq/iptv-transcoder
0
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)