Files
server_IPTV/static/IPTV.py
T
ubuntuandClaude Sonnet 5 3eb0d5e8cd Add device-side log file, remote terminal, and streaming error reporting
IPTV.py now writes logs to a rotating file and forwards critical VLC
playback errors (start timeout, MediaPlayerEncounteredError) to the
server instead of losing everything to unredirected print() calls.
The web UI's device menu gets a working Terminal (to run diagnostic
commands like tail on the log) and a Recent errors view backed by a
capped last_errors list on each device document, keeping DB volume
bounded to the last 5 entries per device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 22:09:51 +00:00

1391 lines
44 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# pylint: disable=W0613, C0116
# type: ignore[union-attr]
import logging
import logging.handlers
import vlc
import os
import re
import sys
import json
import queue
import psutil
import requests
import time
import threading
import tempfile
import socketio
import uuid
import subprocess
import tkinter as tk
from io import BytesIO
from wifi import Cell, Scheme
import sqlite3 as lite
from PIL import Image
from datetime import datetime
import urllib3
try:
from PIL import ImageTk
PIL_IMAGETK_AVAILABLE = True
except Exception:
ImageTk = None
PIL_IMAGETK_AVAILABLE = False
sio = socketio.Client(
ssl_verify=False,
reconnection=True,
reconnection_attempts=0,
reconnection_delay=2,
reconnection_delay_max=15,
)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ----------------------------------------------------------------
# LOGGING (fichier local avec rotation, consultable via le Terminal
# du menu device sur le site, ex: tail -n 200 iptv.log)
# ----------------------------------------------------------------
LOG_FILE_PATH = "/home/pi/Documents/iptv.log"
LOG_FILE_MAX_BYTES = 1_000_000
LOG_FILE_BACKUP_COUNT = 5
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
_log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(_log_formatter)
logger.addHandler(_console_handler)
try:
_file_handler = logging.handlers.RotatingFileHandler(
LOG_FILE_PATH, maxBytes=LOG_FILE_MAX_BYTES, backupCount=LOG_FILE_BACKUP_COUNT
)
_file_handler.setFormatter(_log_formatter)
logger.addHandler(_file_handler)
except OSError as exc:
logger.warning("Unable to open log file %s: %s", LOG_FILE_PATH, exc)
class IdleScreenManager:
def __init__(self):
self.root = None
self.thread = None
self.command_queue = queue.Queue()
self.started = False
self.visible = False
self.enabled = True
self.clock_var = None
self.subtitle_var = None
self.footer_var = None
self.cards_frame = None
self.empty_var = None
self.empty_label = None
self.logo_refs = []
def start(self):
if self.started or not self.enabled or not idle_screen_supported():
return
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
self.started = True
def show(self):
if not idle_screen_supported():
return
self.start()
self.command_queue.put(("show", None))
def hide(self):
if not self.started:
return
self.command_queue.put(("hide", None))
def refresh(self):
if not self.started:
return
self.command_queue.put(("refresh", None))
def _run(self):
try:
self.root = tk.Tk()
self.root.configure(bg="#08111d")
self.root.title("IPTV Idle Screen")
self.root.attributes("-fullscreen", True)
self.root.attributes("-topmost", True)
self.root.bind("<Escape>", lambda event: self.root.attributes("-fullscreen", False))
header = tk.Frame(self.root, bg="#08111d")
header.pack(fill="x", padx=60, pady=(45, 20))
title = tk.Label(
header,
text="Latest Channels",
fg="#f7fbff",
bg="#08111d",
font=("Helvetica", 34, "bold"),
anchor="w",
)
title.pack(fill="x")
self.subtitle_var = tk.StringVar(value="No channel running")
subtitle = tk.Label(
header,
textvariable=self.subtitle_var,
fg="#9ab4c8",
bg="#08111d",
font=("Helvetica", 18),
anchor="w",
)
subtitle.pack(fill="x", pady=(8, 0))
self.clock_var = tk.StringVar(value="")
clock_label = tk.Label(
header,
textvariable=self.clock_var,
fg="#6ee7b7",
bg="#08111d",
font=("Helvetica", 18, "bold"),
anchor="e",
)
clock_label.pack(fill="x", pady=(12, 0))
recent_frame = tk.Frame(self.root, bg="#0f1b2d", highlightbackground="#16324b", highlightthickness=1)
recent_frame.pack(fill="both", expand=True, padx=60, pady=(10, 20))
self.empty_var = tk.StringVar(value="Loading recent channels...")
self.empty_label = tk.Label(
recent_frame,
textvariable=self.empty_var,
fg="#f7fbff",
bg="#0f1b2d",
font=("Helvetica", 20),
justify="center",
anchor="center",
padx=28,
pady=28,
)
self.empty_label.pack(fill="both", expand=True)
self.cards_frame = tk.Frame(recent_frame, bg="#0f1b2d")
self.cards_frame.pack(fill="both", expand=True, padx=18, pady=18)
for column_index in range(IDLE_SCREEN_CARD_COLUMNS):
self.cards_frame.grid_columnconfigure(column_index, weight=1, uniform="idle_cards")
self.footer_var = tk.StringVar(value="Waiting for a channel selection")
footer = tk.Label(
self.root,
textvariable=self.footer_var,
fg="#7b95aa",
bg="#08111d",
font=("Helvetica", 14),
anchor="w",
)
footer.pack(fill="x", padx=60, pady=(0, 30))
self.root.withdraw()
self.root.after(250, self._process_commands)
self.root.after(1000, self._tick_clock)
self.root.after(IDLE_SCREEN_REFRESH_INTERVAL_MS, self._refresh_recent_data)
self.root.mainloop()
except Exception as exc:
logger.warning("Unable to start idle screen: %s", exc)
self.enabled = False
def _process_commands(self):
if self.root is None:
return
while True:
try:
action, payload = self.command_queue.get_nowait()
except queue.Empty:
break
if action == "show":
self.visible = True
self._render_recent_channels()
self.root.deiconify()
self.root.attributes("-fullscreen", True)
self.root.attributes("-topmost", True)
self.root.lift()
elif action == "hide":
self.visible = False
self.root.withdraw()
elif action == "refresh":
self._render_recent_channels()
self.root.after(250, self._process_commands)
def _tick_clock(self):
if self.root is None:
return
self.clock_var.set(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
self.root.after(1000, self._tick_clock)
def _refresh_recent_data(self):
if self.root is None:
return
if self.visible:
sync_recent_channels_from_server()
self._render_recent_channels()
self.root.after(IDLE_SCREEN_REFRESH_INTERVAL_MS, self._refresh_recent_data)
def _render_recent_channels(self):
items = recent[:MAX_RECENT_CHANNELS]
if self.cards_frame is None or self.empty_label is None:
return
self.logo_refs = []
for child in self.cards_frame.winfo_children():
child.destroy()
if not items:
self.empty_var.set("No recent channel history available.")
self.empty_label.lift()
self.footer_var.set("The screen will update when the server sends history or a channel is played locally.")
return
self.empty_label.lower()
for index, item in enumerate(items, start=1):
card = tk.Frame(
self.cards_frame,
bg="#13253a",
highlightbackground="#21425f",
highlightthickness=1,
padx=18,
pady=18,
)
row_index = (index - 1) // IDLE_SCREEN_CARD_COLUMNS
column_index = (index - 1) % IDLE_SCREEN_CARD_COLUMNS
card.grid(row=row_index, column=column_index, sticky="nsew", padx=12, pady=12)
logo_image, logo_status = fetch_channel_logo_photo(item)
logo_label = tk.Label(card, bg="#13253a")
logo_label.pack(pady=(0, 16))
if logo_image is not None:
logo_label.configure(image=logo_image)
self.logo_refs.append(logo_image)
else:
logo_label.configure(
text="No Logo",
fg="#9ab4c8",
font=("Helvetica", 16, "bold"),
)
name = item.get("name_channel") or item.get("name") or "Unknown channel"
name_label = tk.Label(
card,
text=name,
fg="#f7fbff",
bg="#13253a",
font=("Helvetica", 18, "bold"),
wraplength=240,
justify="center",
)
name_label.pack(fill="x")
last_played_at = format_recent_timestamp(item.get("last_played_at"))
meta_label = tk.Label(
card,
text=f"Last played: {last_played_at}",
fg="#89a5ba",
bg="#13253a",
font=("Helvetica", 13),
justify="center",
)
meta_label.pack(fill="x", pady=(10, 0))
self.footer_var.set(f"Showing {len(items)} recent channels cached locally and refreshed from the server")
def idle_screen_supported():
if sys.platform.startswith("linux"):
return bool(os.environ.get("DISPLAY", "").strip())
return True
def format_recent_timestamp(timestamp_value):
if not timestamp_value:
return "-"
normalized_value = str(timestamp_value).replace("Z", "+00:00")
for parser in (datetime.fromisoformat,):
try:
return parser(normalized_value).strftime("%Y-%m-%d %H:%M")
except ValueError:
pass
for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(str(timestamp_value), pattern).strftime("%Y-%m-%d %H:%M")
except ValueError:
pass
return str(timestamp_value)
def build_logo_urls(item):
id_channel = item.get("id_channel", "").strip()
if not id_channel:
return []
return [url_update_server + f"logos/{id_channel}.jpg"]
def create_placeholder_logo():
return Image.new("RGB", IDLE_SCREEN_LOGO_SIZE, color="#20364d")
def fetch_channel_logo_photo(item):
if not server_connection_ready:
return None, "Logo skipped: server connection not ready"
if not PIL_IMAGETK_AVAILABLE:
return None, "Logo skipped: PIL ImageTk unavailable"
cache_key = f"{item.get('id_channel', '')}:{item.get('id_playlist', '')}"
cached_logo = logo_cache.get(cache_key)
if cached_logo is not None:
return cached_logo, "Logo loaded from cache"
image_obj = None
last_error = "Logo fetch failed"
for logo_url in build_logo_urls(item):
try:
response = requests.get(logo_url, timeout=LOGO_REQUEST_TIMEOUT_SECONDS, verify=False)
if not response.ok:
last_error = f"HTTP error {response.status_code}"
continue
image_obj = Image.open(BytesIO(response.content)).convert("RGB")
last_error = f"HTTP {response.status_code}, {len(response.content)} bytes"
break
except Exception as exc:
last_error = f"Request/decode error: {exc}"
image_obj = None
if image_obj is None:
return None, last_error
image_obj.thumbnail(IDLE_SCREEN_LOGO_SIZE, IMAGE_RESAMPLING_LANCZOS)
background = Image.new("RGB", IDLE_SCREEN_LOGO_SIZE, color="#0c1827")
offset_x = max((IDLE_SCREEN_LOGO_SIZE[0] - image_obj.width) // 2, 0)
offset_y = max((IDLE_SCREEN_LOGO_SIZE[1] - image_obj.height) // 2, 0)
background.paste(image_obj, (offset_x, offset_y))
try:
photo_image = ImageTk.PhotoImage(background)
except Exception as exc:
logger.warning("Unable to create Tk logo image: %s", exc)
return None, f"Tk image creation failed: {exc}"
logo_cache[cache_key] = photo_image
return photo_image, last_error
def normalize_recent_channel(item):
return {
"id_channel": str(item.get("id_channel") or item.get("_id") or ""),
"id_playlist": str(item.get("id_playlist") or ""),
"name_channel": str(item.get("name_channel") or item.get("name") or "Unknown channel"),
"url_channel": str(item.get("url_channel") or item.get("url") or ""),
"last_played_at": str(item.get("last_played_at") or item.get("datetime") or datetime.now().isoformat(timespec="seconds")),
}
def load_recent_channels_from_db():
global recent
with db_lock:
cur_ = con.cursor()
cur_.execute(
"""
SELECT id_channel, id_playlist, name_channel, url_channel, last_played_at
FROM recent_channels_cache
ORDER BY position ASC
"""
)
rows = cur_.fetchall()
recent = [
{
"id_channel": str(row[0] or ""),
"id_playlist": str(row[1] or ""),
"name_channel": str(row[2] or ""),
"url_channel": str(row[3] or ""),
"last_played_at": str(row[4] or ""),
}
for row in rows
]
return recent
def save_recent_channels_to_db(items):
global recent
normalized_items = [normalize_recent_channel(item) for item in items[:MAX_RECENT_CHANNELS]]
with db_lock:
cur_ = con.cursor()
cur_.execute("DELETE FROM recent_channels_cache")
for position, item in enumerate(normalized_items):
cur_.execute(
"""
INSERT INTO recent_channels_cache (
position, id_channel, id_playlist, name_channel, url_channel, last_played_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
position,
item["id_channel"],
item["id_playlist"],
item["name_channel"],
item["url_channel"],
item["last_played_at"],
),
)
con.commit()
recent = normalized_items
idle_screen_manager.refresh()
return normalized_items
def prepend_recent_channel(id_channel, url_channel, name_channel, id_playlist):
current_items = load_recent_channels_from_db()
new_item = normalize_recent_channel(
{
"id_channel": id_channel,
"id_playlist": id_playlist,
"name_channel": name_channel,
"url_channel": url_channel,
"last_played_at": datetime.now().isoformat(timespec="seconds"),
}
)
filtered_items = [
item for item in current_items
if not (
item.get("id_channel") == new_item["id_channel"]
and item.get("url_channel") == new_item["url_channel"]
)
]
save_recent_channels_to_db([new_item] + filtered_items)
def parse_server_json_payload(response):
payload = response.json()
if isinstance(payload, str):
payload = json.loads(payload)
return payload
def sync_recent_channels_from_server():
if not server_connection_ready:
return recent
try:
response = requests.get(
url_update_server + "recents",
headers={"Cache-Control": "no-cache"},
timeout=10,
verify=False,
)
response.raise_for_status()
payload = parse_server_json_payload(response)
if not isinstance(payload, list):
return recent
normalized_items = [normalize_recent_channel(item) for item in payload[:MAX_RECENT_CHANNELS]]
if normalized_items:
return save_recent_channels_to_db(normalized_items)
except Exception as exc:
logger.warning("Unable to refresh recent channels from server: %s", exc)
return recent
def focus_vlc_player():
global media_principal
idle_screen_manager.hide()
try:
media_principal.set_fullscreen(True)
except Exception as exc:
logger.warning("Unable to focus VLC player: %s", exc)
STREAMING_ERROR_REPORT_MIN_INTERVAL_SECONDS = 5
_last_streaming_error_report_ts = 0
def report_streaming_error(message):
"""Log a critical streaming error and forward it to the server (best effort,
rate-limited so a burst of VLC errors doesn't flood the socket)."""
global _last_streaming_error_report_ts
logger.error(message)
now = time.time()
if now - _last_streaming_error_report_ts < STREAMING_ERROR_REPORT_MIN_INTERVAL_SECONDS:
return
_last_streaming_error_report_ts = now
try:
if sio.connected:
sio.emit('streaming_error', {'IdDevice': IdDevice, 'message': str(message)})
except Exception as exc:
logger.warning("Unable to report streaming error to server: %s", exc)
def on_vlc_encountered_error(event):
report_streaming_error("VLC encountered a playback error (channel: %s)" % mediaRunning)
def wait_for_vlc_playback_start():
global media_principal
deadline = time.time() + VLC_PLAYBACK_START_TIMEOUT_SECONDS
while time.time() < deadline:
try:
if media_principal.is_playing():
focus_vlc_player()
return True
except Exception as exc:
logger.warning("Unable to determine VLC playback state: %s", exc)
break
time.sleep(VLC_PLAYBACK_POLL_INTERVAL_SECONDS)
report_streaming_error("VLC did not report playback start before timeout (channel: %s)" % mediaRunning)
return False
def start_playback_transition():
threading.Thread(target=wait_for_vlc_playback_start, daemon=True).start()
def show_idle_screen():
if mediaRunning == '':
idle_screen_manager.show()
def hide_idle_screen():
idle_screen_manager.hide()
def pulse_audio_available():
pulse_server = os.environ.get("PULSE_SERVER", "").strip()
if pulse_server:
return True
runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "").strip()
pulse_candidates = []
if runtime_dir:
pulse_candidates.append(os.path.join(runtime_dir, "pulse", "native"))
pulse_candidates.append(f"/run/user/{os.getuid()}/pulse/native")
return any(os.path.exists(candidate) for candidate in pulse_candidates)
def ensure_runtime_environment():
runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "").strip()
if not runtime_dir:
fallback_runtime = f"/tmp/iptv-runtime-{os.getuid()}"
os.makedirs(fallback_runtime, exist_ok=True)
os.environ["XDG_RUNTIME_DIR"] = fallback_runtime
def default_vlc_vout():
forced_vout = os.environ.get("IPTV_VLC_VOUT", "").strip()
if forced_vout:
return forced_vout
platform = sys.platform.lower()
if platform.startswith("linux"):
if os.environ.get("DISPLAY", "").strip():
return "xcb_x11"
return "fb"
return ""
def build_vlc_instance():
vlc_options = [
"--intf=dummy",
"--fullscreen",
"--no-osd",
"--no-video-title-show",
"--no-snapshot-preview",
# network/live buffering
"--network-caching=3000",
"--live-caching=3000",
"--file-caching=1000",
# late-frame handling
"--drop-late-frames",
"--skip-frames",
# reduce timing pressure on live streams
"--clock-jitter=0",
"--clock-synchro=0",
# troubleshooting
"--verbose=2",
]
forced_aout = os.environ.get("IPTV_VLC_AOUT", "").strip()
if forced_aout:
vlc_options.append(f"--aout={forced_aout}")
elif not pulse_audio_available():
vlc_options.append("--aout=alsa")
forced_vout = os.environ.get("IPTV_VLC_VOUT", "").strip()
if forced_vout:
vlc_options.append(f"--vout={forced_vout}")
return vlc.Instance(*vlc_options)
def new_media_player(url):
player = vlc_instance.media_player_new()
media = vlc_instance.media_new(url)
media.add_option(":network-caching=3000")
media.add_option(":live-caching=3000")
media.add_option(":drop-late-frames")
media.add_option(":skip-frames")
media.add_option(":clock-jitter=0")
media.add_option(":clock-synchro=0")
forced_vout = os.environ.get("IPTV_VLC_VOUT", "").strip()
if forced_vout:
media.add_option(f":vout={forced_vout}")
player.set_media(media)
player.event_manager().event_attach(vlc.EventType.MediaPlayerEncounteredError, on_vlc_encountered_error)
return player
def get_snapshot_filepath():
return os.path.join(tempfile.gettempdir(), f"iptv_snapshot_{IdDevice}.jpg")
def upload_snapshot_file(snapshot_path):
if not os.path.exists(snapshot_path):
return False
with open(snapshot_path, "rb") as snapshot_file:
response = requests.post(
url_update_server + 'upload_snapshot',
data={'id_device': IdDevice},
files={'file': (os.path.basename(snapshot_path), snapshot_file, 'image/jpeg')},
timeout=10,
verify=False
)
response.raise_for_status()
return True
def capture_and_upload_snapshot():
global media_principal
global data_running
if not data_running:
return False
snapshot_path = get_snapshot_filepath()
snapshot_status = media_principal.video_take_snapshot(0, snapshot_path, 0, 0)
if snapshot_status != 0:
logger.error("Snapshot capture failed")
return False
upload_snapshot_file(snapshot_path)
return True
def snapshot_loop():
while True:
try:
if auto_snapshot_enabled and data_running:
capture_and_upload_snapshot()
except Exception as exc:
logger.warning("Snapshot loop error: %s", exc)
time.sleep(SNAPSHOT_INTERVAL_SECONDS)
def RepresentsInt(s):
try:
int(s)
return True
except ValueError:
return False
def convertMillis(millis):
seconds=(millis/1000)%60
minutes=(millis/(1000*60))%60
hours=(millis/(1000*60*60))%24
logger.debug("%s %s %s", seconds, minutes, hours)
return seconds, minutes, hours
def is_vpn_connected():
try:
output = subprocess.check_output("ip a | grep tun0", shell=True)
return "tun0" in output.decode()
except subprocess.CalledProcessError:
return False
def connect_vpn():
global vpn_process
if is_vpn_connected():
return "VPN already connected"
subprocess.call("sudo pkill openvpn", shell=True)
subprocess.call("sudo ip link delete tun0", shell=True)
time.sleep(1)
vpn_process = subprocess.Popen(
["sudo", "openvpn", "--config", vpn_config, "--auth-user-pass", auth_file],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
# Wait for connection confirmation
for _ in range(30):
if is_vpn_connected():
return "VPN connected"
time.sleep(1)
return "VPN connection failed"
def disconnect_vpn():
global vpn_process
subprocess.call("sudo pkill openvpn", shell=True)
subprocess.call("sudo ip link delete tun0", shell=True)
vpn_process = None
return "VPN disconnected"
def findInfo(tag, s):
end = '"'
if (s.find(tag)>-1):
t = s[s.find(tag)+len(tag)+2:s.rfind(end)]
return t[0:t.find(end)]
else:
return ""
def restart_process():
try:
logger.info("Restarting process")
p = psutil.Process(os.getpid())
for handler in p.get_open_files() + p.connections():
os.close(handler.fd)
except Exception as e:
logger.error(e)
python = sys.executable
os.execl(python, python, *sys.argv)
def restart_main_iptv_process():
python = sys.executable
main_script = os.path.join(path, 'IPTV.py')
os.execl(python, python, main_script)
def stop():
global media_principal
global mediaRunning
global data_running
data_running = {}
mediaRunning = ''
media_principal.stop()
sync_recent_channels_from_server()
show_idle_screen()
def display_overlay_message(message, duration_ms=10000):
global media_principal
try:
media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1)
media_principal.video_set_marquee_string(vlc.VideoMarqueeOption.Text, message)
media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Size, 42)
media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Timeout, duration_ms)
media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Position, 8)
except Exception as exc:
logger.warning("Unable to display overlay message: %s", exc)
def playMedia(id_channel, url_channel, name_channel, id_playlist):
global media_principal
global mediaRunning
global recent
global data_running
try:
media_principal.stop()
media_principal.release()
except Exception as e:
logger.warning("Unable to reset VLC player: %s", e)
media_principal = new_media_player(url_channel)
try:
media_principal.set_fullscreen(True)
except Exception as e:
logger.warning("Unable to enable fullscreen: %s", e)
media_principal.audio_set_volume(200)
media_principal.play()
start_playback_transition()
mediaRunning = name_channel
data_running = {'name_channel':name_channel, 'id_channel':id_channel, 'id_playlist':id_playlist, 'url_channel': url_channel, 'progress':0}
prepend_recent_channel(id_channel, url_channel, name_channel, id_playlist)
logger.info("Playback started: %s", data_running)
media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1)
media_principal.video_set_marquee_string(vlc.VideoMarqueeOption.Text, name_channel)
media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Size, 48) # Font size
media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Timeout, 7000) # Duration in milliseconds
media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Position, 8) # Position on screen
def update_source():
global path
r = requests.get(url_update_server+'get_source', headers={'Cache-Control': 'no-cache'}, verify=False)
r.raise_for_status()
temp_path = path + 'IPTV.py.tmp'
try:
with open(temp_path, 'wb') as updated_file:
updated_file.write(r.content)
os.replace(temp_path, path + 'IPTV.py')
except Exception as exc:
logger.error("Error during update_source: %s", exc)
if os.path.exists(temp_path):
os.remove(temp_path)
raise
def wait_for_internet_connection():
while True:
logger.info("Checking connexion ...")
for check_url, verify_ssl in CONNECTIVITY_CHECK_TARGETS:
try:
requests.get(check_url, timeout=CONNECTIVITY_CHECK_TIMEOUT_SECONDS, verify=verify_ssl)
logger.info("Connectivity check succeeded with %s", check_url)
return
except requests.RequestException as exc:
logger.warning("Connectivity check failed for %s: %s", check_url, exc)
time.sleep(CONNECTIVITY_CHECK_RETRY_SECONDS)
def ensure_socket_connection(force_reset=False):
global last_connect_attempt_ts
global server_connection_ready
with socket_connect_lock:
if sio.connected and not force_reset:
server_connection_ready = True
return True
now = time.time()
if not force_reset and (now - last_connect_attempt_ts) < SOCKET_RETRY_MIN_INTERVAL:
return sio.connected
last_connect_attempt_ts = now
try:
if force_reset:
try:
sio.disconnect()
except Exception:
pass
wait_for_internet_connection()
logger.info("Attempting socket connection...")
sio.connect(
url_update_server,
wait=True,
wait_timeout=SOCKET_CONNECT_TIMEOUT,
transports=['websocket', 'polling']
)
logger.info("Socket connection ready")
server_connection_ready = True
return True
except Exception as exc:
logger.warning("Socket reconnection failed: %s", exc)
server_connection_ready = False
return False
def polling_server():
global media_principal
global data_running
global status_vpn
logger.info("Polling loop started")
while True:
try:
if not sio.connected:
ensure_socket_connection()
time.sleep(POLLING_INTERVAL_SECONDS)
continue
#r = requests.get(url_update_server+'ping?name=hirondelle&', allow_redirects=True)
data_running_tmp = data_running
milli = media_principal.get_time()
percentage = media_principal.get_position()*100
if data_running:
logger.debug("Current playback state: %s", data_running_tmp)
data_running = {'name_channel':data_running_tmp['name_channel'], 'id_channel':data_running_tmp['id_channel'], 'id_playlist':data_running_tmp['id_playlist'], 'url_channel':data_running_tmp['url_channel'], 'progress':milli, 'percentage':percentage}
sio.emit('polling', {
'name': name_device,
'IdDevice': IdDevice,
'mediaRunning': mediaRunning,
'vpn_status': is_vpn_connected(),
'data_running': data_running,
'auto_snapshot_enabled': auto_snapshot_enabled,
'snapshot_interval_seconds': SNAPSHOT_INTERVAL_SECONDS
})
except Exception as exc:
logger.warning("Polling failed, will reconnect: %s", exc)
ensure_socket_connection(force_reset=True)
time.sleep(POLLING_INTERVAL_SECONDS)
def sendMessage(fromSidDevice, toSidDevice, typeCommand, payload, num_packet):
new_data = {'fromSidDevice': fromSidDevice, 'toSidDevice': toSidDevice, 'typeCommand': typeCommand, 'payload': payload, 'packet':num_packet}
logger.info("Sending message: %s", new_data)
if sio.connected or ensure_socket_connection():
sio.emit('communication', new_data)
else:
logger.warning("Socket disconnected, unable to send message")
@sio.event
def connect():
global server_connection_ready
server_connection_ready = True
logger.info("Connection established")
@sio.event
def connect_error(data):
logger.error("Connection error: %s", data)
@sio.event
def communication(data):
toSidDevice = data['toSidDevice']
fromSidDevice = data['fromSidDevice']
typeCommand = data['typeCommand']
global recent
global media_principal
global mediaRunning
global data_running
# --------------------------------
# OFF
# --------------------------------
if typeCommand == "off":
stop()
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Off', 0)
sendMessage(toSidDevice, fromSidDevice, 'playing', 'no channel', 0)
# --------------------------------
# VPN MANAGEMENT
# --------------------------------
if typeCommand == "vpn":
command_vpn = data['payload']
if command_vpn == "connect":
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Start VPN Connexion', 0)
result = connect_vpn()
elif command_vpn == "status":
result = "connected" if is_vpn_connected() else "disconnected"
elif command_vpn == "disconnect":
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Start VPN Disconnexion', 0)
result = disconnect_vpn()
else:
result = "Unknown command"
sendMessage(toSidDevice, fromSidDevice, 'warning', result, 0)
# --------------------------------
# PLAY A CHANNEL ON
# --------------------------------
if typeCommand == "on":
logger.info("Switch on requested")
data_channel = data['payload']
name_channel = data_channel['name']
url_channel = data_channel['url']
id_channel = data_channel['id_channel']
id_playlist = data_channel['id_playlist']
logger.info("Playing channel id=%s name=%s url=%s", id_channel, name_channel, url_channel)
playMedia(id_channel, url_channel, name_channel, id_playlist)
sendMessage(toSidDevice, fromSidDevice, 'playing', name_channel, 0)
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Starting media', 0)
# --------------------------------
# GET IP ADDRESS
# --------------------------------
if typeCommand == "getIP":
my_IP = requests.get("https://api.ipify.org").text
sendMessage(toSidDevice, fromSidDevice, 'warning', 'IP : ' + my_IP, 0)
# --------------------------------
# GET CHANNEL RUNNING
# --------------------------------
if typeCommand == "getChannelRunning":
new_data ={}
if mediaRunning == '':
mr = 'no channel'
percentage = 0
milli = 0
id_channel = ''
else:
mr = mediaRunning
percentage = media_principal.get_position()*100
milli = media_principal.get_time()
id_channel = data_running['id_channel']
new_data = {'name': mr, 'percentage':percentage, 'millisec':milli, 'id_channel': id_channel}
sendMessage(toSidDevice, fromSidDevice, 'playing', new_data, 0)
# --------------------------------
# SET SUBTITLE
# --------------------------------
if typeCommand == "subtitle":
track = data['payload']
media_principal.video_set_spu(track)
sendMessage(data['toSidDevice'], data['fromSidDevice'], 'warning', 'Track Num ' + str(track), 0)
# --------------------------------
# RESUME
# --------------------------------
if typeCommand == "resume":
media_principal.set_pause(0)
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Resume done', 0)
# --------------------------------
# PAUSE
# --------------------------------
if typeCommand == "pause":
media_principal.set_pause(1)
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Pause done', 0)
# --------------------------------
# GET SOFTWARE VERSION
# --------------------------------
if typeCommand == "version":
version_message = 'Version ' + str(num_version)
sendMessage(toSidDevice, fromSidDevice, 'warning', version_message, 0)
display_overlay_message(version_message, 10000)
# --------------------------------
# AUTO SNAPSHOT TOGGLE
# --------------------------------
if typeCommand == "toggleAutoSnapshot":
global auto_snapshot_enabled
auto_snapshot_enabled = bool(data['payload'])
state_label = 'enabled' if auto_snapshot_enabled else 'disabled'
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Auto snapshot ' + state_label, 0)
# --------------------------------
# SNAPSHOT INTERVAL
# --------------------------------
if typeCommand == "setSnapshotInterval":
global SNAPSHOT_INTERVAL_SECONDS
try:
interval_seconds = int(data['payload'])
except (TypeError, ValueError):
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Invalid snapshot interval', 0)
else:
if interval_seconds < 2:
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Snapshot interval must be at least 2 seconds', 0)
else:
SNAPSHOT_INTERVAL_SECONDS = interval_seconds
sendMessage(
toSidDevice,
fromSidDevice,
'warning',
'Snapshot interval set to ' + str(SNAPSHOT_INTERVAL_SECONDS) + ' seconds',
0
)
# --------------------------------
# MOVETO
# --------------------------------
if typeCommand == "moveto":
percentage = data['payload']
new_position_value = float(percentage) / 100
media_principal.set_position(new_position_value)
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Moved to ' + str(percentage), 0)
# --------------------------------
# UPDATE SOFTWARE
# --------------------------------
if typeCommand == "update_source":
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Updating source and restarting client', 0)
time.sleep(1)
update_source()
restart_main_iptv_process()
# --------------------------------
# REBOOT
# --------------------------------
if typeCommand == "reboot":
os.system('sudo reboot')
sendMessage(toSidDevice, fromSidDevice, 'warning', 'start rebooting ...', 0)
# --------------------------------
# CHANGE DEVICE NAME AND REBUILD CONFIG FILE
# --------------------------------
if typeCommand == "changeDeviceName":
global name_device
name_device = data['payload']
logger.info("Change device name to: %s", name_device)
query = "UPDATE settings SET valueParameter = '" + name_device + "' WHERE nameParameter = 'deviceName'"
cur_ = con.cursor()
cur_.execute(query)
con.commit()
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Configuration saved', 0)
# --------------------------------
# GET LIST OF WIFI NETWORKS
# --------------------------------
if typeCommand == "listSSID":
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Retreiving Wifi data', 0)
cells = Cell.all('wlan0')
resWifi = []
for c in cells:
item = []
item.append(c.ssid)
item.append(str(c.signal))
item.append(str(c.quality))
item.append(str(c.encrypted))
resWifi.append(item)
sendMessage(toSidDevice, fromSidDevice, 'wifiupdate', resWifi, 0)
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Wifi data received', 0)
# --------------------------------
# TAKE SNAPSHOT
# --------------------------------
if typeCommand == "takeSnapshot":
try:
logger.info("Take snapshot requested")
time.sleep(1)
capture_and_upload_snapshot()
except Exception as e:
sendMessage(toSidDevice, fromSidDevice, 'warning', e, 0)
# --------------------------------
# GET NUMBER OF CHANNELS
# --------------------------------
if typeCommand == "numberOfChannels":
numberOfChannels = len(channels)
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Number of channels : ' + str(numberOfChannels), 0)
# --------------------------------
# Command line
# --------------------------------
if typeCommand == "command_line":
logger.info("Command line requested: %s", data['payload'])
records = re.split(r' ', data['payload'])
process = subprocess.Popen(records, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in process.stdout:
logger.info("command_line output: %s", line.rstrip())
sendMessage(toSidDevice, fromSidDevice, 'display_command_result', line, 0)
@sio.event
def disconnect():
global server_connection_ready
server_connection_ready = False
logger.warning("Disconnected from server")
# --------------------------------
# INITIALIZATION DATABASE
# --------------------------------
def initDB():
global con
try:
con = lite.connect(path+db_file, check_same_thread=False)
cur = con.cursor()
cur.execute('SELECT SQLITE_VERSION()')
data = cur.fetchone()
logger.info("Connexion succeed : SQLite version: %s", data)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS recent_channels_cache (
position INTEGER PRIMARY KEY,
id_channel TEXT,
id_playlist TEXT,
name_channel TEXT NOT NULL,
url_channel TEXT NOT NULL,
last_played_at TEXT NOT NULL
)
"""
)
con.commit()
except (lite.Error, e):
logger.error("Error %s:", e.args[0])
sys.exit(1)
def initIPTV():
global token_telegram
global name_device
global channels
global playlists
global con
global IdDevice
global recent
# GET ID UNIQUE DEVICE OR CREATE IT
query = "SELECT valueParameter FROM settings WHERE nameParameter = 'IdDevice'"
cur_ = con.cursor()
cur_.execute(query)
record = cur_.fetchone()
if record:
IdDevice = record[0]
logger.info("Device ID: %s", IdDevice)
else:
logger.info("No ID device found, generating one")
IdDevice = str(uuid.uuid4())
query = "INSERT INTO settings (nameParameter, valueParameter) VALUES ('IdDevice', '"+IdDevice+"')"
cur_ = con.cursor()
cur_.execute(query)
con.commit()
# GET DEVICE NAME
query = "SELECT valueParameter FROM settings WHERE nameParameter = 'deviceName'"
cur_ = con.cursor()
cur_.execute(query)
valueParameter = cur_.fetchone()
if valueParameter is not None:
name_device = valueParameter
logger.info("Update device name : %s", name_device)
# SAVE PID
query = "DELETE FROM settings WHERE nameParameter = 'pid'"
cur_ = con.cursor()
cur_.execute(query)
con.commit()
pid = os.getpid()
query = "INSERT INTO settings (nameParameter, valueParameter) VALUES ('pid', '"+str(pid)+"')"
cur_ = con.cursor()
cur_.execute(query)
con.commit()
# ----------------------------------------------------------------
# ----------------------------------------------------------------
num_version='5.0.6'
# ----------------------------------------------------------------
# ----------------------------------------------------------------
# ----------------------------------------------------------------
# VARIABLES GLOBAL
# ----------------------------------------------------------------
url_update_server = 'https://iptv.mrk.ovh/'
#url_update_server = 'https://tv.mrk.ovh:5011/'
db_file = 'config.db'
path = os.path.dirname(os.path.realpath(__file__))+'/'
con = None
pid = os.getppid()
logger.info("PID : %s", pid)
channels = []
playlists = []
IdDevice = "0"
NUM_MAX_CHANNEL = 500000
recent = []
mediaRunning = ''
status_vpn = 'disconnected'
data_running = {}
auto_snapshot_enabled = True
SNAPSHOT_INTERVAL_SECONDS = 10
POLLING_INTERVAL_SECONDS = 10
SOCKET_CONNECT_TIMEOUT = 10
SOCKET_RETRY_MIN_INTERVAL = 3
CONNECTIVITY_CHECK_TIMEOUT_SECONDS = 3
CONNECTIVITY_CHECK_RETRY_SECONDS = 2
CONNECTIVITY_CHECK_TARGETS = [
(url_update_server, False),
("https://www.google.com/generate_204", True),
("http://clients3.google.com/generate_204", False),
]
last_connect_attempt_ts = 0
server_connection_ready = False
socket_connect_lock = threading.Lock()
db_lock = threading.Lock()
MAX_RECENT_CHANNELS = 12
IDLE_SCREEN_REFRESH_INTERVAL_MS = 60000
idle_screen_manager = IdleScreenManager()
VLC_PLAYBACK_START_TIMEOUT_SECONDS = 8
VLC_PLAYBACK_POLL_INTERVAL_SECONDS = 0.2
IDLE_SCREEN_CARD_COLUMNS = 3
IDLE_SCREEN_LOGO_SIZE = (220, 124)
LOGO_REQUEST_TIMEOUT_SECONDS = 5
logo_cache = {}
IMAGE_RESAMPLING_LANCZOS = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
ensure_runtime_environment()
vlc_instance = build_vlc_instance()
media_principal = new_media_player("http://mag.hi-ott.com:80/120643/NS3MV8YZTS/51648")
token_telegram = "token"
name_device = ''
vpn_process = None
vpn_config = "/home/pi/Documents/ca1629.nordvpn.com.tcp.ovpn"
auth_file = "/home/pi/Documents/auth.txt"
# ----------------------------------------------------------------
# ---------------------------------------------------------------
# (logging configuré plus haut dans le fichier, juste après les imports)
# ----------------------------------------------------------------
# LOOP PRINCIPAL
# ----------------------------------------------------------------
if __name__ == '__main__':
initDB()
initIPTV()
wait_for_internet_connection()
load_recent_channels_from_db()
show_idle_screen()
threading.Thread(target=snapshot_loop, daemon=True).start()
ensure_socket_connection(force_reset=True)
sync_recent_channels_from_server()
idle_screen_manager.refresh()
polling_server()