generated from unai/python_boilerplate
Compare commits
No commits in common. "9e8f3bcb4737e913e3d053695ed6009119a60238" and "545958028d0ef1db25657d3597c7b1bbf2b5806a" have entirely different histories.
9e8f3bcb47
...
545958028d
33
README.md
33
README.md
@ -8,11 +8,9 @@ Servicio Python que genera y actualiza automáticamente listas de reproducción
|
||||
|
||||
## Características
|
||||
- **Actualización automática** — Regenera la playlist en intervalos configurables
|
||||
- **EPG integrado** — Descarga y filtra automáticamente la guía de programación (XMLTV)
|
||||
- **Filtros avanzados** — Incluye canales por prefijo o excluye por contenido del nombre
|
||||
- **Servidor HTTP integrado** — Sirve el archivo M3U y EPG directamente sin dependencias externas
|
||||
- **Servidor HTTP integrado** — Sirve el archivo M3U directamente sin dependencias externas
|
||||
- **Docker ready** — Despliegue simple con Docker Compose
|
||||
- **Escritura atómica** — Actualiza archivos sin interrumpir descargas activas
|
||||
- **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
|
||||
|
||||
@ -43,10 +41,9 @@ Servicio Python que genera y actualiza automáticamente listas de reproducción
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. **Accede a la playlist y EPG:**
|
||||
4. **Accede a la playlist:**
|
||||
```
|
||||
http://localhost:8080/playlist.m3u
|
||||
http://localhost:8080/epg.xml
|
||||
```
|
||||
|
||||
### Sin Docker
|
||||
@ -77,21 +74,6 @@ Servicio Python que genera y actualiza automáticamente listas de reproducción
|
||||
| `PORT` | Puerto del servidor HTTP local | ❌ | `8080` |
|
||||
| `UPDATE_INTERVAL` | Intervalo de actualización (segundos) | ❌ | `3600` |
|
||||
| `OUTPUT_FILE` | Nombre del archivo M3U generado | ❌ | `playlist.m3u` |
|
||||
| `INCLUDE_TEXT` | Lista JSON de prefijos. Solo canales que **empiecen** con alguno | ❌ | `[]` |
|
||||
| `EXCLUDE_TEXT` | Lista JSON de textos. Excluye canales que **contengan** alguno | ❌ | `[]` |
|
||||
|
||||
### Filtros
|
||||
|
||||
- **`INCLUDE_TEXT`**: Filtra canales que **empiecen** con alguno de los textos especificados (lógica OR). Si está vacío, incluye todos los canales.
|
||||
- **`EXCLUDE_TEXT`**: Excluye canales cuyo nombre **contenga** alguno de los textos (lógica OR). Se aplica después del filtro de inclusión.
|
||||
- Los mismos filtros se aplican automáticamente al EPG.
|
||||
|
||||
Ejemplo:
|
||||
```env
|
||||
# Solo canales que empiecen con "ES:" o "UK:", excluyendo los que contengan "Adult"
|
||||
INCLUDE_TEXT=["ES:", "UK:"]
|
||||
EXCLUDE_TEXT=["Adult", "XXX"]
|
||||
```
|
||||
|
||||
## Arquitectura
|
||||
|
||||
@ -105,15 +87,10 @@ EXCLUDE_TEXT=["Adult", "XXX"]
|
||||
│ │ Fetch API │ │ │ ThreadingHTTPServer │ │
|
||||
│ │ (Xtream Codes) │ │ │ │ │
|
||||
│ └───────┬────────┘ │ │ GET /playlist.m3u │ │
|
||||
│ │ │ │ GET /epg.xml │ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌───────▼────────┐ │ └────────────────────────────┘ │
|
||||
│ │ Generate M3U │ │ │
|
||||
│ │ + Apply Filter │──┼──────► public/playlist.m3u │
|
||||
│ └───────┬────────┘ │ │
|
||||
│ │ │ │
|
||||
│ ┌───────▼────────┐ │ │
|
||||
│ │ Fetch & Filter │ │ │
|
||||
│ │ EPG (XMLTV) │──┼──────► public/epg.xml │
|
||||
│ │ (Atomic Write) │──┼──────► public/playlist.m3u │
|
||||
│ └───────┬────────┘ │ │
|
||||
│ │ │ │
|
||||
│ sleep(interval) │ │
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "m3u_list_builder"
|
||||
version = "0.3.2"
|
||||
version = "0.0.0"
|
||||
description = "Python tool to build M3U lists from various sources."
|
||||
authors = ["Unai Blazquez <unaibg2000@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
@ -304,104 +304,6 @@ class TestPlaylistManager:
|
||||
# Verificar URL
|
||||
assert lines[2] == "http://iptv.com/live/user/pass/123.ts"
|
||||
|
||||
def test_write_m3u_uses_epg_channel_id_when_present(self, temp_dir):
|
||||
"""Test: _write_m3u usa epg_channel_id en tvg-id cuando está presente."""
|
||||
channel = [
|
||||
{
|
||||
"name": "Test Channel",
|
||||
"stream_id": 123,
|
||||
"stream_icon": "http://icon.com/test.png",
|
||||
"category_id": "5",
|
||||
"epg_channel_id": "test.channel.epg",
|
||||
}
|
||||
]
|
||||
|
||||
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
|
||||
with patch("m3u_list_builder.playlist.settings") as mock_settings:
|
||||
mock_settings.host = "http://iptv.com"
|
||||
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
|
||||
|
||||
manager = PlaylistManager()
|
||||
manager._write_m3u(channel)
|
||||
|
||||
output_file = temp_dir / "playlist.m3u"
|
||||
content = output_file.read_text()
|
||||
|
||||
# Debe usar epg_channel_id en tvg-id
|
||||
assert 'tvg-id="test.channel.epg"' in content
|
||||
# El nombre debe seguir apareciendo al final de EXTINF
|
||||
assert ",Test Channel" in content
|
||||
|
||||
def test_write_m3u_falls_back_to_name_when_epg_channel_id_empty(self, temp_dir):
|
||||
"""Test: _write_m3u usa name como tvg-id cuando epg_channel_id está vacío."""
|
||||
channel = [
|
||||
{
|
||||
"name": "Fallback Channel",
|
||||
"stream_id": 456,
|
||||
"stream_icon": "",
|
||||
"category_id": "1",
|
||||
"epg_channel_id": "",
|
||||
}
|
||||
]
|
||||
|
||||
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
|
||||
with patch("m3u_list_builder.playlist.settings") as mock_settings:
|
||||
mock_settings.host = "http://iptv.com"
|
||||
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
|
||||
|
||||
manager = PlaylistManager()
|
||||
manager._write_m3u(channel)
|
||||
|
||||
output_file = temp_dir / "playlist.m3u"
|
||||
content = output_file.read_text()
|
||||
|
||||
# Debe usar name como fallback en tvg-id
|
||||
assert 'tvg-id="Fallback Channel"' in content
|
||||
|
||||
def test_write_m3u_falls_back_to_name_when_epg_channel_id_missing(self, temp_dir):
|
||||
"""Test: _write_m3u usa name como tvg-id cuando epg_channel_id no existe."""
|
||||
channel = [
|
||||
{
|
||||
"name": "No EPG Channel",
|
||||
"stream_id": 789,
|
||||
"stream_icon": "",
|
||||
"category_id": "2",
|
||||
# No epg_channel_id field at all
|
||||
}
|
||||
]
|
||||
|
||||
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
|
||||
with patch("m3u_list_builder.playlist.settings") as mock_settings:
|
||||
mock_settings.host = "http://iptv.com"
|
||||
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
|
||||
|
||||
manager = PlaylistManager()
|
||||
manager._write_m3u(channel)
|
||||
|
||||
output_file = temp_dir / "playlist.m3u"
|
||||
content = output_file.read_text()
|
||||
|
||||
# Debe usar name como fallback en tvg-id
|
||||
assert 'tvg-id="No EPG Channel"' in content
|
||||
|
||||
def test_write_m3u_missing_fields_uses_defaults(self, temp_dir, minimal_channel):
|
||||
"""Test: _write_m3u usa valores por defecto para campos faltantes."""
|
||||
with patch("m3u_list_builder.playlist.PUBLIC_DIR", temp_dir):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user