16 Commits

Author SHA1 Message Date
6c357dd977 Merge pull request 'Dev' (#9) from Dev into main
All checks were successful
CI/CD Pipeline / test-and-lint (pull_request) Successful in 27s
CI/CD Pipeline / publish-container (pull_request) Has been skipped
CI/CD Pipeline / test-and-lint (push) Successful in 26s
CI/CD Pipeline / publish-container (push) Successful in 5m14s
Reviewed-on: #9
2026-02-02 16:45:33 +00:00
7733c7e2ac Merge pull request 'Actualizar el README para acomodar la nueva funcionalidad' (#8) from unai-patch-5 into main
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 25s
CI/CD Pipeline / publish-container (push) Has been skipped
Reviewed-on: #8
2026-02-02 16:40:37 +00:00
d95d6d6c58 Actualizar el README para acomodar la nueva funcionalidad
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 30s
CI/CD Pipeline / test-and-lint (pull_request) Successful in 27s
CI/CD Pipeline / publish-container (push) Has been skipped
CI/CD Pipeline / publish-container (pull_request) Has been skipped
2026-02-02 16:40:26 +00:00
c1d9b6bf54 feat: add include_text and exclude_text filters to settings and playlist tests
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 25s
CI/CD Pipeline / publish-container (push) Has been skipped
CI/CD Pipeline / test-and-lint (pull_request) Successful in 33s
CI/CD Pipeline / publish-container (pull_request) Has been skipped
2026-02-02 16:28:51 +00:00
757ed91aae Linting, line too long 2026-02-02 16:26:52 +00:00
76c12408bf Merge pull request 'fix: sacar poetry.lock del gitignore y subirlo' (#7) from Dev into main
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 28s
CI/CD Pipeline / publish-container (push) Successful in 6m17s
Reviewed-on: #7
2026-02-01 22:24:57 +00:00
0b5c5af13d Merge pull request 'Updated package access token, again' (#6) from unai-patch-4 into main
Some checks failed
CI/CD Pipeline / test-and-lint (push) Successful in 33s
CI/CD Pipeline / publish-container (push) Failing after 24s
Reviewed-on: #6
2026-02-01 22:11:29 +00:00
7c191b7ecd Updated package access token, again
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 39s
CI/CD Pipeline / test-and-lint (pull_request) Successful in 32s
CI/CD Pipeline / publish-container (push) Has been skipped
CI/CD Pipeline / publish-container (pull_request) Has been skipped
2026-02-01 22:11:14 +00:00
73778732ba Merge pull request 'Update action user for gitea publish container' (#5) from unai-patch-3 into main
Some checks failed
CI/CD Pipeline / test-and-lint (push) Successful in 32s
CI/CD Pipeline / publish-container (push) Failing after 14s
Reviewed-on: #5
2026-02-01 21:53:53 +00:00
86f8e67318 Update action user for gitea publish container
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 32s
CI/CD Pipeline / publish-container (push) Has been skipped
CI/CD Pipeline / test-and-lint (pull_request) Successful in 33s
CI/CD Pipeline / publish-container (pull_request) Has been skipped
2026-02-01 21:53:10 +00:00
ed3045f6a4 Merge pull request 'Updated permissions on gitea workflow' (#4) from unai-patch-2 into main
Some checks failed
CI/CD Pipeline / test-and-lint (push) Successful in 37s
CI/CD Pipeline / publish-container (push) Failing after 16s
Reviewed-on: #4
2026-02-01 21:39:08 +00:00
a89ee4f1a0 Updated permissions on gitea workflow
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 36s
CI/CD Pipeline / test-and-lint (pull_request) Successful in 32s
CI/CD Pipeline / publish-container (push) Has been skipped
CI/CD Pipeline / publish-container (pull_request) Has been skipped
2026-02-01 21:38:55 +00:00
79bbeeea00 Merge pull request 'Actualizar README.md' (#3) from unai-patch-1 into main
Some checks failed
CI/CD Pipeline / test-and-lint (push) Successful in 32s
CI/CD Pipeline / publish-container (push) Failing after 3m47s
Reviewed-on: #3
2026-02-01 19:44:45 +00:00
11668ce326 Actualizar README.md
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 36s
CI/CD Pipeline / test-and-lint (pull_request) Successful in 31s
CI/CD Pipeline / publish-container (push) Has been skipped
CI/CD Pipeline / publish-container (pull_request) Has been skipped
2026-02-01 19:44:25 +00:00
8124949d2e Merge pull request 'Dev' (#2) from Dev into main
All checks were successful
CI/CD Pipeline / test-and-lint (push) Successful in 33s
CI/CD Pipeline / publish-container (push) Has been skipped
Reviewed-on: #2
2026-02-01 19:39:48 +00:00
659c6350d5 Merge pull request 'Dev' (#1) from Dev into main
Some checks failed
CI/CD Pipeline / test-and-lint (push) Failing after 26s
CI/CD Pipeline / publish-container (push) Has been skipped
Reviewed-on: #1
2026-02-01 18:18:50 +00:00
6 changed files with 702 additions and 4 deletions

View File

@@ -33,6 +33,9 @@ jobs:
needs: test-and-lint
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v3
- name: Prepare Docker Metadata
@@ -47,8 +50,8 @@ jobs:
uses: docker/login-action@v2
with:
registry: ${{ steps.meta.outputs.REGISTRY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ github.actor }}
password: ${{ secrets.PACKAGES_TOKEN }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v4

143
README.md
View File

@@ -0,0 +1,143 @@
# Xtream Codes M3U List Builder
[![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://www.docker.com/)
Servicio Python que genera y actualiza automáticamente listas de reproducción M3U a partir de servidores **Xtream Codes API**. Ideal para centralizar y servir playlists IPTV de forma local.
## Características
- **Actualización automática** — Regenera la playlist en intervalos configurables
- **Servidor HTTP integrado** — Sirve el archivo M3U directamente sin dependencias externas
- **Docker ready** — Despliegue simple con Docker Compose
- **Escritura atómica** — Actualiza el archivo sin interrumpir descargas activas
- **Seguridad** — Se ejecuta con usuario no privilegiado en contenedor
- **Multi-hilo** — Servidor threaded para manejar múltiples clientes simultáneos
## Inicio Rápido
### Con Docker (Recomendado)
1. **Clona el repositorio:**
```bash
git clone https://github.com/tu-usuario/xtream_codes_m3u_list_builder.git
cd xtream_codes_m3u_list_builder
```
2. **Crea un archivo `.env`:**
```env
HOST=http://tu-servidor-iptv.com
USERNAME=tu_usuario
PASSWORD=tu_contraseña
PORT=8080
UPDATE_INTERVAL=3600
OUTPUT_FILE=playlist.m3u
INCLUDE_TEXT="ES:"
EXCLUDE_TEXT=["XXX","Adultos","24/7"]
```
3. **Ejecuta con Docker Compose:**
```bash
docker compose up -d
```
4. **Accede a la playlist:**
```
http://localhost:8080/playlist.m3u
```
### Sin Docker
1. **Requisitos:**
- Python 3.14+
- [Poetry](https://python-poetry.org/)
2. **Instalación:**
```bash
poetry install
```
3. **Configuración:** Crea un archivo `.env` (ver ejemplo arriba) o exporta las variables de entorno.
4. **Ejecución:**
```bash
poetry run python -m m3u_list_builder.main
```
## Configuración
| Variable | Descripción | Requerido | Default |
|----------|-------------|-----------|---------|
| `HOST` | URL del servidor Xtream Codes | ✅ | — |
| `USERNAME` | Usuario del servicio IPTV | ✅ | — |
| `PASSWORD` | Contraseña del servicio IPTV | ✅ | — |
| `PORT` | Puerto del servidor HTTP local | ❌ | `8080` |
| `UPDATE_INTERVAL` | Intervalo de actualización (segundos) | ❌ | `3600` |
| `OUTPUT_FILE` | Nombre del archivo M3U generado | ❌ | `playlist.m3u` |
## Arquitectura
```
┌─────────────────────────────────────────────────────────┐
│ M3U List Builder │
├──────────────────────┬──────────────────────────────────┤
│ Updater Thread │ HTTP Server │
│ │ │
│ ┌────────────────┐ │ ┌────────────────────────────┐ │
│ │ Fetch API │ │ │ ThreadingHTTPServer │ │
│ │ (Xtream Codes) │ │ │ │ │
│ └───────┬────────┘ │ │ GET /playlist.m3u │ │
│ │ │ │ │ │
│ ┌───────▼────────┐ │ └────────────────────────────┘ │
│ │ Generate M3U │ │ │
│ │ (Atomic Write) │──┼──────► public/playlist.m3u │
│ └───────┬────────┘ │ │
│ │ │ │
│ sleep(interval) │ │
│ ↓ │ │
│ [loop] │ │
└──────────────────────┴──────────────────────────────────┘
```
## Estructura del Proyecto
```
├── src/m3u_list_builder/
│ ├── __init__.py
│ ├── config.py # Configuración con Pydantic Settings
│ ├── main.py # Punto de entrada
│ ├── playlist.py # Lógica de generación M3U
│ └── server.py # Servidor HTTP
├── tests/ # Tests unitarios
├── public/ # Directorio servido (playlist generada)
├── Dockerfile # Multi-stage build
├── docker-compose.yml
└── pyproject.toml # Dependencias y configuración
```
## Testing
```bash
# Ejecutar tests
poetry run pytest
# Con cobertura
poetry run pytest --cov
# Verificar estilo (Ruff)
poetry run ruff check src/
```
## Desarrollo
El proyecto utiliza:
- **Pydantic Settings** — Validación de configuración
- **Requests** — Cliente HTTP
- **Ruff** — Linter y formatter
- **Pytest** — Testing framework
---
<p align="center">
<sub>Desarrollado con ❤️ para la comunidad IPTV</sub>
</p>

View File

@@ -27,11 +27,11 @@ class Settings(BaseSettings):
# Filtros por prefijo de nombre de canal
include_text: list[str] = Field(
default_factory=list,
description="Lista de textos obligatorios. Si se define, el canal DEBE contener al menos uno.",
description="Lista de textos obligatorios. Debe contener al menos uno.",
)
exclude_text: list[str] = Field(
default_factory=list,
description="Lista de textos prohibidos. Si el canal contiene uno, se descarta.",
description="Lista de textos prohibidos",
)
model_config = SettingsConfigDict(

View File

@@ -28,6 +28,55 @@ def sample_channels():
]
@pytest.fixture
def channels_for_filtering():
"""Fixture con canales para probar filtros de inclusión/exclusión."""
return [
{
"name": "ESPN Sports",
"stream_id": 201,
"stream_icon": "http://example.com/espn.png",
"category_id": "sports",
},
{
"name": "HBO Movies",
"stream_id": 202,
"stream_icon": "http://example.com/hbo.png",
"category_id": "movies",
},
{
"name": "CNN News",
"stream_id": 203,
"stream_icon": "http://example.com/cnn.png",
"category_id": "news",
},
{
"name": "Sports Center ESPN",
"stream_id": 204,
"stream_icon": "http://example.com/sc.png",
"category_id": "sports",
},
{
"name": "BBC World",
"stream_id": 205,
"stream_icon": "http://example.com/bbc.png",
"category_id": "news",
},
{
"name": "Adult Content",
"stream_id": 206,
"stream_icon": "http://example.com/adult.png",
"category_id": "adult",
},
{
"name": "FOX News",
"stream_id": 207,
"stream_icon": "http://example.com/fox.png",
"category_id": "news",
},
]
@pytest.fixture
def empty_channels():
"""Fixture con lista vacía de canales."""
@@ -51,5 +100,7 @@ def mock_settings(monkeypatch):
port = 8080
update_interval = 60
output_file = "test_playlist.m3u"
include_text = []
exclude_text = []
return MockSettings()

View File

@@ -175,3 +175,133 @@ class TestSettings:
# No debe lanzar excepción
settings = Settings()
assert not hasattr(settings, "unknown_field")
# ========================================
# Tests para include_text y exclude_text
# ========================================
def test_settings_include_text_default_empty_list(self, monkeypatch):
"""Test: include_text tiene valor por defecto lista vacía."""
monkeypatch.setenv("HOST", "http://test.com")
monkeypatch.setenv("USERNAME", "user")
monkeypatch.setenv("PASSWORD", "pass")
from m3u_list_builder.config import Settings
settings = Settings(_env_file=None)
assert settings.include_text == []
assert isinstance(settings.include_text, list)
def test_settings_exclude_text_default_empty_list(self, monkeypatch):
"""Test: exclude_text tiene valor por defecto lista vacía."""
monkeypatch.setenv("HOST", "http://test.com")
monkeypatch.setenv("USERNAME", "user")
monkeypatch.setenv("PASSWORD", "pass")
from m3u_list_builder.config import Settings
settings = Settings(_env_file=None)
assert settings.exclude_text == []
assert isinstance(settings.exclude_text, list)
def test_settings_include_text_from_env_json(self, monkeypatch):
"""Test: include_text acepta formato JSON desde variable de entorno."""
monkeypatch.setenv("HOST", "http://test.com")
monkeypatch.setenv("USERNAME", "user")
monkeypatch.setenv("PASSWORD", "pass")
monkeypatch.setenv("INCLUDE_TEXT", '["ESPN", "CNN"]')
from m3u_list_builder.config import Settings
settings = Settings(_env_file=None)
assert settings.include_text == ["ESPN", "CNN"]
def test_settings_exclude_text_from_env_json(self, monkeypatch):
"""Test: exclude_text acepta formato JSON desde variable de entorno."""
monkeypatch.setenv("HOST", "http://test.com")
monkeypatch.setenv("USERNAME", "user")
monkeypatch.setenv("PASSWORD", "pass")
monkeypatch.setenv("EXCLUDE_TEXT", '["Adult", "XXX"]')
from m3u_list_builder.config import Settings
settings = Settings(_env_file=None)
assert settings.exclude_text == ["Adult", "XXX"]
def test_settings_include_and_exclude_together(self, monkeypatch):
"""Test: include_text y exclude_text pueden usarse juntos."""
monkeypatch.setenv("HOST", "http://test.com")
monkeypatch.setenv("USERNAME", "user")
monkeypatch.setenv("PASSWORD", "pass")
monkeypatch.setenv("INCLUDE_TEXT", '["Sports", "News"]')
monkeypatch.setenv("EXCLUDE_TEXT", '["Adult"]')
from m3u_list_builder.config import Settings
settings = Settings(_env_file=None)
assert settings.include_text == ["Sports", "News"]
assert settings.exclude_text == ["Adult"]
def test_settings_include_text_single_value(self, monkeypatch):
"""Test: include_text acepta un solo valor en lista."""
monkeypatch.setenv("HOST", "http://test.com")
monkeypatch.setenv("USERNAME", "user")
monkeypatch.setenv("PASSWORD", "pass")
monkeypatch.setenv("INCLUDE_TEXT", '["OnlyThis"]')
from m3u_list_builder.config import Settings
settings = Settings(_env_file=None)
assert settings.include_text == ["OnlyThis"]
assert len(settings.include_text) == 1
def test_settings_exclude_text_single_value(self, monkeypatch):
"""Test: exclude_text acepta un solo valor en lista."""
monkeypatch.setenv("HOST", "http://test.com")
monkeypatch.setenv("USERNAME", "user")
monkeypatch.setenv("PASSWORD", "pass")
monkeypatch.setenv("EXCLUDE_TEXT", '["BlockThis"]')
from m3u_list_builder.config import Settings
settings = Settings(_env_file=None)
assert settings.exclude_text == ["BlockThis"]
assert len(settings.exclude_text) == 1
def test_settings_include_text_empty_json_array(self, monkeypatch):
"""Test: include_text acepta array JSON vacío."""
monkeypatch.setenv("HOST", "http://test.com")
monkeypatch.setenv("USERNAME", "user")
monkeypatch.setenv("PASSWORD", "pass")
monkeypatch.setenv("INCLUDE_TEXT", "[]")
from m3u_list_builder.config import Settings
settings = Settings(_env_file=None)
assert settings.include_text == []
def test_settings_from_env_file_with_filters(self, tmp_path, monkeypatch):
"""Test: Settings lee filtros desde archivo .env."""
env_file = tmp_path / ".env"
env_file.write_text(
'HOST=http://test.com\n'
'USERNAME=user\n'
'PASSWORD=pass\n'
'INCLUDE_TEXT=["HBO", "ESPN"]\n'
'EXCLUDE_TEXT=["Adult"]\n'
)
from m3u_list_builder.config import Settings
settings = Settings(_env_file=str(env_file))
assert settings.include_text == ["HBO", "ESPN"]
assert settings.exclude_text == ["Adult"]

View File

@@ -243,6 +243,8 @@ class TestPlaylistManager:
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
@@ -277,6 +279,8 @@ class TestPlaylistManager:
mock_settings.username = "user"
mock_settings.password = "pass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
@@ -306,6 +310,8 @@ class TestPlaylistManager:
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
@@ -459,3 +465,368 @@ class TestPlaylistManager:
manager.loop()
assert iterations == 3
# ========================================
# Tests para filtros include_text y exclude_text
# Complejidad Alta - Múltiples paths de filtrado
# ========================================
def test_write_m3u_include_text_filters_by_prefix(
self, temp_dir, channels_for_filtering
):
"""Test: include_text filtra canales que EMPIEZAN con el texto."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = ["ESPN"]
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# "ESPN Sports" empieza con "ESPN" - DEBE incluirse
assert "ESPN Sports" in content
# "Sports Center ESPN" NO empieza con "ESPN" - NO debe incluirse
assert "Sports Center ESPN" not in content
# Otros canales no deben estar
assert "HBO Movies" not in content
assert "CNN News" not in content
def test_write_m3u_include_text_case_insensitive(
self, temp_dir, channels_for_filtering
):
"""Test: include_text es case-insensitive."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = ["espn"] # minúsculas
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# "ESPN Sports" debe incluirse aunque el filtro está en minúsculas
assert "ESPN Sports" in content
def test_write_m3u_include_text_multiple_prefixes(
self, temp_dir, channels_for_filtering
):
"""Test: include_text acepta múltiples prefijos (OR logic)."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = ["ESPN", "CNN", "BBC"]
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# Canales que empiezan con ESPN, CNN o BBC
assert "ESPN Sports" in content
assert "CNN News" in content
assert "BBC World" in content
# Canales que no empiezan con ninguno
assert "HBO Movies" not in content
assert "FOX News" not in content
def test_write_m3u_include_text_empty_includes_all(
self, temp_dir, channels_for_filtering
):
"""Test: include_text vacío incluye todos los canales."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# Todos los canales deben estar incluidos
for channel in channels_for_filtering:
assert channel["name"] in content
def test_write_m3u_exclude_text_filters_by_contains(
self, temp_dir, channels_for_filtering
):
"""Test: exclude_text filtra canales que CONTIENEN el texto."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
mock_settings.exclude_text = ["Adult"]
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# "Adult Content" contiene "Adult" - NO debe incluirse
assert "Adult Content" not in content
# Otros canales deben estar
assert "ESPN Sports" in content
assert "HBO Movies" in content
def test_write_m3u_exclude_text_matches_anywhere_in_name(
self, temp_dir, channels_for_filtering
):
"""Test: exclude_text busca en cualquier parte del nombre."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
mock_settings.exclude_text = ["News"] # Está en medio/final
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# "CNN News" y "FOX News" contienen "News" - NO deben incluirse
assert "CNN News" not in content
assert "FOX News" not in content
# Otros canales deben estar
assert "ESPN Sports" in content
assert "HBO Movies" in content
def test_write_m3u_exclude_text_case_insensitive(
self, temp_dir, channels_for_filtering
):
"""Test: exclude_text es case-insensitive."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
mock_settings.exclude_text = ["adult"] # minúsculas
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# "Adult Content" debe excluirse aunque el filtro está en minúsculas
assert "Adult Content" not in content
def test_write_m3u_exclude_text_multiple_patterns(
self, temp_dir, channels_for_filtering
):
"""Test: exclude_text acepta múltiples patrones (OR logic)."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
mock_settings.exclude_text = ["Adult", "News"]
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# Canales que contienen "Adult" o "News" no deben incluirse
assert "Adult Content" not in content
assert "CNN News" not in content
assert "FOX News" not in content
# Otros canales deben estar
assert "ESPN Sports" in content
assert "HBO Movies" in content
assert "BBC World" in content
def test_write_m3u_include_and_exclude_combined(
self, temp_dir, channels_for_filtering
):
"""Test: include_text y exclude_text funcionan juntos."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
# Solo canales que empiezan con "ESPN" o "Sports"
mock_settings.include_text = ["ESPN", "Sports"]
# Pero excluir los que contienen "Center"
mock_settings.exclude_text = ["Center"]
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# "ESPN Sports" empieza con "ESPN" y no contiene "Center" - INCLUIR
assert "ESPN Sports" in content
# "Sports Center ESPN" empieza con "Sports" pero contiene "Center" - EXCLUIR
assert "Sports Center ESPN" not in content
def test_write_m3u_include_filters_before_exclude(
self, temp_dir, channels_for_filtering
):
"""Test: include se aplica antes que exclude."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = ["CNN"]
mock_settings.exclude_text = ["News"]
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# "CNN News" empieza con "CNN" pero contiene "News" - EXCLUIR
assert "CNN News" not in content
# Solo debe quedar el header
assert content.strip() == "#EXTM3U"
def test_write_m3u_no_match_for_include_results_in_empty(
self, temp_dir, channels_for_filtering
):
"""Test: si ningún canal coincide con include, resultado vacío."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = ["NONEXISTENT"]
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# Solo header, sin canales
assert content == "#EXTM3U\n"
def test_write_m3u_exclude_all_results_in_empty(
self, temp_dir, channels_for_filtering
):
"""Test: si todos los canales son excluidos, resultado vacío."""
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = []
# Excluir texto común a todos
mock_settings.exclude_text = ["a", "e", "i", "o", "u"]
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels_for_filtering)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# Solo header, sin canales (todos tienen vocales)
assert content == "#EXTM3U\n"
def test_write_m3u_include_startswith_not_contains(self, temp_dir):
"""Test: include usa startswith, NO contains - verificación explícita."""
channels = [
{"name": "ABC News", "stream_id": 1, "stream_icon": "", "category_id": "1"},
{
"name": "News ABC",
"stream_id": 2,
"stream_icon": "",
"category_id": "1",
}, # Contiene ABC pero no empieza
{
"name": "ZABC Channel",
"stream_id": 3,
"stream_icon": "",
"category_id": "1",
}, # Contiene ABC pero no empieza
]
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
with patch("m3u_list_builder.playlist.settings") as mock_settings:
mock_settings.host = "http://test-iptv.com"
mock_settings.username = "testuser"
mock_settings.password = "testpass"
mock_settings.output_file = "playlist.m3u"
mock_settings.include_text = ["ABC"]
mock_settings.exclude_text = []
from m3u_list_builder.playlist import PlaylistManager
manager = PlaylistManager()
manager._write_m3u(channels)
output_file = temp_dir / "playlist.m3u"
content = output_file.read_text()
# Solo "ABC News" debe estar - empieza con ABC
assert "ABC News" in content
# "News ABC" y "ZABC Channel" NO deben estar
assert "News ABC" not in content
assert "ZABC Channel" not in content