From 8d2d482c056ad8f398750048f06ce15e60d798f7 Mon Sep 17 00:00:00 2001 From: unai Date: Sun, 1 Feb 2026 18:10:45 +0000 Subject: [PATCH 1/6] Add pydantic-settings and requests dependencies --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c812d5a..f87a622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ packages = [{include = "my_project", from = "src"}] [tool.poetry.dependencies] python = "^3.14" +pydantic-settings = "^2.12.0" +requests = "^2.32.5" [tool.poetry.group.dev.dependencies] pytest = "^9.0.2" -- 2.49.1 From ce62dc70050915c92aab83d64a28e4ee3b405429 Mon Sep 17 00:00:00 2001 From: unai Date: Sun, 1 Feb 2026 18:11:30 +0000 Subject: [PATCH 2/6] Cleaned devcontainer to avoid ssh agent conflicts --- .devcontainer/devcontainer.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index db404d3..0fc0f70 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -27,12 +27,6 @@ "-v", "/dev:/dev" ], - "mounts": [ - "source=${localEnv:XDG_RUNTIME_DIR}/ssh-agent.socket,target=/tmp/ssh-agent.socket,type=bind" - ], - "remoteEnv": { - "SSH_AUTH_SOCK": "/tmp/ssh-agent.socket" - }, "remoteUser": "root", "postCreateCommand": "poetry install" } \ No newline at end of file -- 2.49.1 From 9446cd7589f28279e0fc91a08ac7c10dd61a0150 Mon Sep 17 00:00:00 2001 From: unai Date: Sun, 1 Feb 2026 18:12:08 +0000 Subject: [PATCH 3/6] Renamed project name --- src/{my-project => m3u_list_builder}/__init__.py | 0 src/my-project/main.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{my-project => m3u_list_builder}/__init__.py (100%) delete mode 100644 src/my-project/main.py diff --git a/src/my-project/__init__.py b/src/m3u_list_builder/__init__.py similarity index 100% rename from src/my-project/__init__.py rename to src/m3u_list_builder/__init__.py diff --git a/src/my-project/main.py b/src/my-project/main.py deleted file mode 100644 index e69de29..0000000 -- 2.49.1 From 5b1903087a6ae3a4ce4621239b68fba76e8a065c Mon Sep 17 00:00:00 2001 From: unai Date: Sun, 1 Feb 2026 18:12:25 +0000 Subject: [PATCH 4/6] Add configuration, main module, playlist manager, and server for M3U list generation --- src/m3u_list_builder/config.py | 29 ++++++++++++ src/m3u_list_builder/main.py | 37 ++++++++++++++++ src/m3u_list_builder/playlist.py | 75 ++++++++++++++++++++++++++++++++ src/m3u_list_builder/server.py | 29 ++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 src/m3u_list_builder/config.py create mode 100644 src/m3u_list_builder/main.py create mode 100644 src/m3u_list_builder/playlist.py create mode 100644 src/m3u_list_builder/server.py 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() -- 2.49.1 From 4b9481d16277d27776f81416de6156f94572c5bd Mon Sep 17 00:00:00 2001 From: unai Date: Sun, 1 Feb 2026 18:15:41 +0000 Subject: [PATCH 5/6] Renamed ci yml to yaml --- .gitea/{ci.yml => ci.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .gitea/{ci.yml => ci.yaml} (100%) diff --git a/.gitea/ci.yml b/.gitea/ci.yaml similarity index 100% rename from .gitea/ci.yml rename to .gitea/ci.yaml -- 2.49.1 From 9cfada5b922138c84ab8727519036a71c3cf213c Mon Sep 17 00:00:00 2001 From: unai Date: Sun, 1 Feb 2026 18:16:35 +0000 Subject: [PATCH 6/6] Add CI/CD pipeline configuration for testing and container publishing --- .gitea/{ => workflows}/ci.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .gitea/{ => workflows}/ci.yaml (100%) diff --git a/.gitea/ci.yaml b/.gitea/workflows/ci.yaml similarity index 100% rename from .gitea/ci.yaml rename to .gitea/workflows/ci.yaml -- 2.49.1