diff --git a/src/m3u_list_builder/config.py b/src/m3u_list_builder/config.py new file mode 100644 index 0000000..40b6875 --- /dev/null +++ b/src/m3u_list_builder/config.py @@ -0,0 +1,29 @@ +"""Configuración de la aplicación para generar listas M3U desde un servicio IPTV.""" + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Configuración de la aplicación para generar listas M3U desde un servicio IPTV.""" + + # Variables obligatorias (sin default) + host: str = Field(..., description="URL del host remoto (ej: http://iptv.com)") + username: str = Field(..., description="Usuario del servicio IPTV") + password: str = Field(..., description="Contraseña del servicio IPTV") + + # Variables con valores por defecto + port: int = Field(8080, description="Puerto del servidor local") + update_interval: int = Field( + 3600, description="Intervalo de actualización en segundos" + ) + output_file: str = Field("playlist.m3u", description="Nombre del archivo de salida") + + # Configuración para leer de variables de entorno y archivo .env si existe + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore" + ) + + +# Instancia única de configuración +settings = Settings() diff --git a/src/m3u_list_builder/main.py b/src/m3u_list_builder/main.py new file mode 100644 index 0000000..a38460e --- /dev/null +++ b/src/m3u_list_builder/main.py @@ -0,0 +1,37 @@ +""" +Módulo principal para iniciar el servicio de construcción de listas M3U.""" + +import logging +import sys +import threading + +from my_project.config import settings +from my_project.playlist import PlaylistManager +from my_project.server import run_server + +# Configuración básica de logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) + + +def main(): + """Función principal para iniciar el servicio.""" + logger = logging.getLogger("main") + logger.info(f"Iniciando servicio para host: {settings.host}") + + # 1. Hilo de actualización (Daemon para que muera si el main muere) + manager = PlaylistManager() + updater_thread = threading.Thread( + target=manager.loop, daemon=True, name="UpdaterThread" + ) + updater_thread.start() + + # 2. Servidor Web (Hilo principal) + run_server() + + +if __name__ == "__main__": + main() diff --git a/src/m3u_list_builder/playlist.py b/src/m3u_list_builder/playlist.py new file mode 100644 index 0000000..c417814 --- /dev/null +++ b/src/m3u_list_builder/playlist.py @@ -0,0 +1,75 @@ +"""Módulo para gestionar la generación y actualización de listas M3U.""" + +import logging +import time +from pathlib import Path + +import requests +from my_project.config import settings + +logger = logging.getLogger(__name__) + + +class PlaylistManager: + """Clase para gestionar la generación y actualización de listas M3U.""" + + def __init__(self): + self.running = False + + def fetch_and_generate(self): + """Descarga datos y regenera el archivo M3U.""" + logger.info("Iniciando actualización de playlist...") + + url = f"{settings.host}/player_api.php" + params = { + "username": settings.username, + "password": settings.password, + "action": "get_live_streams", + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + self._write_m3u(data) + logger.info( + f"Playlist actualizada exitosamente. Total canales: {len(data)}" + ) + + except requests.RequestException as e: + logger.error(f"Error de red al obtener playlist: {e}") + except Exception as e: + logger.exception(f"Error inesperado actualizando playlist: {e}") + + def _write_m3u(self, channels: list): + """Escribe el archivo M3U en disco de forma atómica.""" + # Escribimos en un temporal y renombramos para evitar lecturas de archivo corrupto + temp_file = Path(f"{settings.output_file}.tmp") + final_file = Path(settings.output_file) + + with open(temp_file, "w", encoding="utf-8") as f: + f.write("#EXTM3U\n") + for channel in channels: + name = channel.get("name", "Unknown") + stream_id = channel.get("stream_id") + icon = channel.get("stream_icon", "") + cat_id = channel.get("category_id", "") + + # Construir URL directa + stream_url = f"{settings.host}/live/{settings.username}/{settings.password}/{stream_id}.ts" + + f.write( + f'#EXTINF:-1 tvg-id="{name}" tvg-logo="{icon}" group-title="Cat_{cat_id}",{name}\n' + ) + f.write(f"{stream_url}\n") + + # Reemplazo atómico + temp_file.replace(final_file) + + def loop(self): + """Bucle infinito de actualización.""" + self.running = True + while self.running: + self.fetch_and_generate() + time.sleep(settings.update_interval) diff --git a/src/m3u_list_builder/server.py b/src/m3u_list_builder/server.py new file mode 100644 index 0000000..4c97007 --- /dev/null +++ b/src/m3u_list_builder/server.py @@ -0,0 +1,29 @@ +import logging +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +from my_project.config import settings + +logger = logging.getLogger(__name__) + + +def run_server(): + """Inicia el servidor HTTP bloqueante.""" + handler = partial(SimpleHTTPRequestHandler, directory=".") + + # Truco: SimpleHTTPRequestHandler sirve el directorio actual, + # asegurarse de que el CWD es correcto o mover el archivo a una carpeta 'public' + # Para este ejemplo simple, asumimos que se ejecuta donde se genera el archivo. + + server_address = ("", settings.port) + httpd = ThreadingHTTPServer(server_address, SimpleHTTPRequestHandler) + + logger.info( + f"Servidor M3U activo en http://localhost:{settings.port}/{settings.output_file}" + ) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.server_close()