#!/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("", 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()