Files
script.service.ultimate/routes/epg.py
T

517 lines
22 KiB
Python
Raw Normal View History

2026-01-20 15:26:41 +01:00
#!/usr/bin/env python3
"""
2026-06-15 22:07:34 +02:00
EPG (Electronic Program Guide) routes.
These routes provide program schedule information for channels.
Endpoints:
GET /api/epg/status – Cache/config health check
GET /api/epg/xmltv-channels – All channel IDs + display names from XMLTV source
GET /api/providers/<provider>/epg – Bulk XMLTV export (download)
GET /api/providers/<provider>/epg/grid – Multi-channel grid view (JSON)
GET /api/providers/<provider>/channels/<channel_id>/epg – Single-channel program list
GET /api/providers/<provider>/programs/<program_id> – Deep program detail
GET /api/providers/<provider>/epg-mapping – Load channel→EPG ID mapping
POST /api/providers/<provider>/epg-mapping – Persist channel→EPG ID mapping
2026-01-20 15:26:41 +01:00
"""
2026-06-15 22:07:34 +02:00
import gzip
import os
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
2026-01-20 15:26:41 +01:00
from bottle import request, response
2026-06-30 12:03:17 +02:00
from streaming_providers.base.epg_operations import EPGOperations, EPGWindowError
2026-06-15 22:07:34 +02:00
from streaming_providers.base.utils import logger
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _parse_time_param(value: str | None) -> datetime | None:
"""
Parse a query-string time value into a timezone-aware datetime.
Accepts:
- Unix timestamp (integer string): "1710000000"
- ISO 8601 string: "2024-03-10T00:00:00Z"
Returns None and logs a warning if the value cannot be parsed.
"""
if not value:
return None
try:
return datetime.fromtimestamp(int(value), tz=timezone.utc)
except (ValueError, TypeError):
pass
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
logger.warning(f"Invalid time format, ignoring: {value!r}")
return None
def _open_xml(file_path: str):
"""Open a plain or gzip-compressed XMLTV file for text reading."""
if file_path.endswith(".gz"):
return gzip.open(file_path, "rt", encoding="utf-8")
return open(file_path, "r", encoding="utf-8")
2026-01-20 15:26:41 +01:00
2026-06-15 22:07:34 +02:00
# ---------------------------------------------------------------------------
# Route setup
# ---------------------------------------------------------------------------
2026-01-20 15:26:41 +01:00
def setup_epg_routes(app, manager, service):
2026-06-15 22:07:34 +02:00
"""Register all EPG-related routes on *app*."""
2026-01-20 15:26:41 +01:00
2026-06-15 22:07:34 +02:00
# EPGOperations is instantiated once here so that EPGManager, EPGCache,
# EPGMapping and their VFS helpers are constructed only a single time,
2026-04-13 12:05:26 +02:00
# not on every incoming HTTP request.
epg_ops = EPGOperations(manager.registry)
2026-06-15 22:07:34 +02:00
# ------------------------------------------------------------------
# 1. EPG system status
# ------------------------------------------------------------------
2026-01-20 15:26:41 +01:00
@app.route("/api/epg/status", method="GET")
def get_epg_status():
2026-06-15 22:07:34 +02:00
"""Return EPG configuration and cache status."""
2026-01-20 15:26:41 +01:00
try:
2026-06-15 22:07:34 +02:00
configured = bool(service.epg_url) and service.epg_url != "https://example.com/epg.xml.gz"
2026-01-20 15:26:41 +01:00
result = {
2026-06-15 22:07:34 +02:00
"configured": configured,
"epg_url": service.epg_url or "Not configured",
2026-01-20 15:26:41 +01:00
"cache_valid": False,
"cache_path": None,
"channel_count": 0,
"environment_used": False,
}
env_url = os.environ.get("ULTIMATE_EPG_URL")
if env_url and env_url == service.epg_url:
result["environment_used"] = True
2026-06-15 22:07:34 +02:00
if configured and hasattr(service, "epg_manager") and service.epg_manager:
2026-01-20 15:26:41 +01:00
try:
2026-06-15 22:07:34 +02:00
xml_path = service.epg_manager.cache.get_cached_file_path()
2026-01-20 15:26:41 +01:00
if xml_path:
result["cache_valid"] = True
result["cache_path"] = xml_path
2026-06-15 22:07:34 +02:00
channel_ids: set[str] = set()
2026-01-20 15:26:41 +01:00
try:
2026-06-15 22:07:34 +02:00
with _open_xml(xml_path) as f:
for event, elem in ET.iterparse(f, events=("start",)):
2026-01-20 15:26:41 +01:00
if elem.tag == "channel":
2026-06-15 22:07:34 +02:00
cid = elem.get("id")
if cid:
channel_ids.add(cid)
2026-01-20 15:26:41 +01:00
elem.clear()
result["channel_count"] = len(channel_ids)
except Exception as parse_err:
result["parse_error"] = str(parse_err)
except Exception as cache_err:
result["cache_error"] = str(cache_err)
else:
result["hint"] = "Please configure EPG URL in Advanced settings"
return result
except Exception as e:
logger.error(f"Error getting EPG status: {e}")
response.status = 500
return {"error": str(e)}
2026-06-15 22:07:34 +02:00
# ------------------------------------------------------------------
# 2. XMLTV channel list
# ------------------------------------------------------------------
2026-01-20 15:26:41 +01:00
@app.route("/api/epg/xmltv-channels", method="GET")
def get_epg_xmltv_channels():
2026-06-15 22:07:34 +02:00
"""Return all channel IDs and display names from the XMLTV source file."""
2026-01-20 15:26:41 +01:00
try:
if not hasattr(service, "epg_manager") or not service.epg_manager:
response.status = 404
return {"error": "EPG module not available"}
2026-06-15 22:07:34 +02:00
if not service.epg_url or service.epg_url == "https://example.com/epg.xml.gz":
2026-01-20 15:26:41 +01:00
response.status = 400
return {
"error": "EPG URL not configured",
"hint": "Please configure a valid EPG URL in Advanced settings",
"current_url": service.epg_url,
}
cache = service.epg_manager.cache
2026-06-15 22:07:34 +02:00
logger.info(f"EPG xmltv-channels: using URL {service.epg_url}")
2026-01-20 15:26:41 +01:00
xml_path = cache.get_or_download(service.epg_url)
2026-06-15 22:07:34 +02:00
if not xml_path or not os.path.exists(xml_path):
2026-01-20 15:26:41 +01:00
response.status = 404
return {
"error": f"EPG file not available from {service.epg_url}",
"hint": "Check if the URL is accessible and contains valid XMLTV data.",
}
file_size = os.path.getsize(xml_path)
2026-06-15 22:07:34 +02:00
logger.info(f"Parsing EPG file: {xml_path} ({file_size} bytes)")
2026-01-20 15:26:41 +01:00
2026-06-15 22:07:34 +02:00
channel_ids: list[str] = []
channel_map: dict[str, str] = {}
current_id: str | None = None
current_names: list[str] = []
2026-01-20 15:26:41 +01:00
2026-06-15 22:07:34 +02:00
with _open_xml(xml_path) as f:
for event, elem in ET.iterparse(f, events=("start", "end")):
2026-01-20 15:26:41 +01:00
if event == "start" and elem.tag == "channel":
2026-06-15 22:07:34 +02:00
current_id = elem.get("id")
current_names = []
2026-01-20 15:26:41 +01:00
elif event == "end" and elem.tag == "display-name":
2026-06-15 22:07:34 +02:00
if current_id and elem.text:
current_names.append(elem.text.strip())
2026-01-20 15:26:41 +01:00
elif event == "end" and elem.tag == "channel":
2026-06-15 22:07:34 +02:00
if current_id:
channel_ids.append(current_id)
channel_map[current_id] = current_names[0] if current_names else current_id
current_id = None
2026-01-20 15:26:41 +01:00
if event == "end":
elem.clear()
sorted_channels = sorted(channel_ids)
2026-06-15 22:07:34 +02:00
logger.info(f"Found {len(sorted_channels)} channels in EPG")
2026-01-20 15:26:41 +01:00
return {
"channels": sorted_channels,
2026-06-15 22:07:34 +02:00
"channel_map": channel_map,
2026-01-20 15:26:41 +01:00
"count": len(sorted_channels),
"source_url": service.epg_url,
"cache_path": xml_path,
"cache_size_bytes": file_size,
}
2026-06-15 22:07:34 +02:00
except ET.ParseError as e:
logger.error(f"XML parse error in EPG file: {e}")
2026-01-20 15:26:41 +01:00
response.status = 500
return {
2026-06-15 22:07:34 +02:00
"error": f"Failed to parse EPG XML file: {e}",
2026-01-20 15:26:41 +01:00
"hint": "The EPG file may be malformed or not valid XMLTV format.",
}
except Exception as e:
logger.error(f"Error getting EPG channels: {e}", exc_info=True)
response.status = 500
2026-06-15 22:07:34 +02:00
return {"error": f"Failed to process EPG file: {e}"}
# ------------------------------------------------------------------
# 3. Bulk XMLTV export (provider-scoped)
# ------------------------------------------------------------------
@app.route("/api/providers/<provider>/epg", method="GET")
def get_provider_epg_xmltv(provider):
"""Download the full XMLTV feed for a provider."""
try:
response.content_type = "application/xml; charset=utf-8"
response.headers["Content-Disposition"] = (
f'attachment; filename="{provider}_epg.xml"'
)
xmltv_data = manager.get_provider_epg_xmltv(
provider_name=provider,
country=request.query.get("country"),
)
if not xmltv_data:
response.status = 404
return {"error": f'EPG data not available for provider "{provider}"'}
return xmltv_data
except ValueError as e:
logger.error(f"XMLTV EPG error for {provider}: {e}")
response.status = 404
return {"error": str(e)}
except Exception as e:
logger.error(f"XMLTV EPG error for {provider}: {e}")
response.status = 500
return {"error": f"Internal server error: {e}"}
# ------------------------------------------------------------------
# 4. Multi-channel grid view (NEW)
# ------------------------------------------------------------------
@app.route("/api/providers/<provider>/epg/grid", method="GET")
def get_provider_epg_grid(provider):
"""
Return a time-windowed grid of programs across multiple channels.
Query params:
start_time – window start (Unix timestamp or ISO 8601); defaults to now
end_time – window end (Unix timestamp or ISO 8601); defaults to now+3h
channels – comma-separated channel IDs to include (omit = all)
country – optional country filter forwarded to the provider
"""
try:
start_time = _parse_time_param(request.query.get("start_time"))
end_time = _parse_time_param(request.query.get("end_time"))
country = request.query.get("country")
raw_channels = request.query.get("channels")
channel_ids = [c.strip() for c in raw_channels.split(",") if c.strip()] if raw_channels else None
grid_data = epg_ops.get_provider_epg_grid(
provider_name=provider,
start_time=start_time,
end_time=end_time,
channel_ids=channel_ids,
country=country,
)
2026-06-17 21:33:47 +02:00
# Serialize EPGEntry objects to dicts here, at the response
# boundary — epg_ops returns EPGEntry objects (not JSON-safe).
grid_data = {
cid: [entry.to_dict() for entry in entries]
for cid, entries in grid_data.items()
}
2026-06-15 22:07:34 +02:00
response.content_type = "application/json; charset=utf-8"
return {
"provider": provider,
"start_time": request.query.get("start_time"),
"end_time": request.query.get("end_time"),
"channels_count": len(grid_data),
"grid": grid_data,
}
2026-06-30 12:03:17 +02:00
except EPGWindowError as e:
logger.warning(f"EPG window rejected for {provider}: {e.message}")
response.status = 416 # Range Not Satisfiable
response.content_type = "application/json; charset=utf-8"
return {
"error": e.message,
"requested_start": e.requested_start,
"requested_end": e.requested_end,
"min_allowed": e.min_allowed,
"max_allowed": e.max_allowed,
"provider_window_days": e.provider_window_days,
}
2026-06-15 22:07:34 +02:00
except ValueError as e:
logger.error(f"Grid EPG error for {provider}: {e}")
response.status = 404
response.content_type = "application/json; charset=utf-8"
return {"error": str(e)}
except Exception as e:
logger.error(f"Grid EPG error for {provider}: {e}")
response.status = 500
response.content_type = "application/json; charset=utf-8"
return {"error": f"Internal server error: {e}"}
# ------------------------------------------------------------------
# 5. Single-channel program list
# ------------------------------------------------------------------
@app.route("/api/providers/<provider>/channels/<channel_id>/epg", method="GET")
def get_channel_epg(provider, channel_id):
"""
Return programs for a single channel within a time window.
Query params:
start_time / start – window start (Unix timestamp or ISO 8601)
end_time / end – window end (Unix timestamp or ISO 8601)
limit – max number of programs to return (default 100)
country – optional country filter
"""
try:
# Accept both old (start_time/end_time) and new (start/end) param names.
start_time = _parse_time_param(
request.query.get("start_time") or request.query.get("start")
)
end_time = _parse_time_param(
request.query.get("end_time") or request.query.get("end")
)
limit = request.query.get("limit", default=100, type=int)
country = request.query.get("country")
programs = epg_ops.get_channel_epg(
provider_name=provider,
channel_id=channel_id,
start_time=start_time,
end_time=end_time,
limit=limit,
country=country,
)
2026-06-17 21:33:47 +02:00
# Serialize EPGEntry objects to dicts here, at the response
# boundary — epg_ops returns EPGEntry objects (not JSON-safe).
programs = [entry.to_dict() for entry in programs]
2026-06-15 22:07:34 +02:00
response.content_type = "application/json; charset=utf-8"
return {
"provider": provider,
"channel_id": channel_id,
"start_time": request.query.get("start_time") or request.query.get("start"),
"end_time": request.query.get("end_time") or request.query.get("end"),
"count": len(programs),
"programs": programs,
}
2026-06-30 12:03:17 +02:00
except EPGWindowError as e:
logger.warning(f"EPG window rejected for {provider}: {e.message}")
response.status = 416 # Range Not Satisfiable
response.content_type = "application/json; charset=utf-8"
return {
"error": e.message,
"requested_start": e.requested_start,
"requested_end": e.requested_end,
"min_allowed": e.min_allowed,
"max_allowed": e.max_allowed,
"provider_window_days": e.provider_window_days,
}
2026-06-15 22:07:34 +02:00
except ValueError as e:
logger.error(f"EPG error for {provider}/{channel_id}: {e}")
response.status = 404
response.content_type = "application/json; charset=utf-8"
return {"error": str(e)}
except Exception as e:
logger.error(f"EPG error for {provider}/{channel_id}: {e}")
response.status = 500
response.content_type = "application/json; charset=utf-8"
return {"error": f"Internal server error: {e}"}
# ------------------------------------------------------------------
# 6. Deep program detail (NEW)
# ------------------------------------------------------------------
@app.route("/api/providers/<provider>/programs/<program_id>", method="GET")
def get_program_details(provider, program_id):
"""
Return full metadata for a single program.
Useful for detail views, catchup deep-links, and series grouping.
"""
try:
program_data = epg_ops.get_program_details(
provider_name=provider,
program_id=program_id,
)
if not program_data:
response.status = 404
response.content_type = "application/json; charset=utf-8"
return {"error": f"Program '{program_id}' not found for provider '{provider}'"}
response.content_type = "application/json; charset=utf-8"
return {
"provider": provider,
"program_id": program_id,
2026-06-18 21:09:18 +02:00
"details": program_data.to_dict(),
2026-06-15 22:07:34 +02:00
}
except ValueError as e:
logger.error(f"Program detail error for {provider}/{program_id}: {e}")
response.status = 404
response.content_type = "application/json; charset=utf-8"
return {"error": str(e)}
except Exception as e:
logger.error(f"Program detail error for {provider}/{program_id}: {e}")
response.status = 500
response.content_type = "application/json; charset=utf-8"
return {"error": f"Internal server error: {e}"}
# ------------------------------------------------------------------
# 7. EPG channel mapping (GET + POST)
# ------------------------------------------------------------------
2026-01-20 15:26:41 +01:00
@app.route("/api/providers/<provider>/epg-mapping", method="GET")
def get_epg_mapping(provider):
2026-06-15 22:07:34 +02:00
"""Return the persisted channel→EPG ID mapping for a provider."""
2026-01-20 15:26:41 +01:00
try:
from streaming_providers.base.utils.vfs import VFS
except ImportError:
return {"provider": provider, "mapping": {}, "exists": False}
try:
vfs = VFS(addon_subdir="")
2026-06-15 22:07:34 +02:00
mapping_file = f"{provider}_epg_mapping.json"
2026-01-20 15:26:41 +01:00
2026-06-15 22:07:34 +02:00
if not vfs.exists(mapping_file):
logger.info(f"No EPG mapping file found for {provider}")
return {"provider": provider, "mapping": {}, "exists": False}
mapping_data = vfs.read_json(mapping_file) or {}
_internal = {"_provider_name", "_created_at", "_updated_at", "_version"}
actual_mapping = {k: v for k, v in mapping_data.items() if k not in _internal}
logger.info(f"Loaded EPG mapping for {provider}: {len(actual_mapping)} channels")
logger.debug(f"Sample mappings: {list(actual_mapping.items())[:3]}")
return {"provider": provider, "mapping": actual_mapping, "exists": True}
2026-01-20 15:26:41 +01:00
except Exception as e:
2026-06-15 22:07:34 +02:00
logger.error(f"Error getting EPG mapping for {provider}: {e}", exc_info=True)
2026-01-20 15:26:41 +01:00
response.status = 500
2026-06-15 22:07:34 +02:00
return {"error": f"Failed to load mapping: {e}"}
2026-01-20 15:26:41 +01:00
@app.route("/api/providers/<provider>/epg-mapping", method="POST")
def save_epg_mapping(provider):
2026-06-15 22:07:34 +02:00
"""Persist a channel→EPG ID mapping for a provider."""
2026-01-20 15:26:41 +01:00
try:
from streaming_providers.base.utils.vfs import VFS
except ImportError:
response.status = 500
return {"error": "VFS module not available"}
try:
try:
mapping_data = request.json.get("mapping", {}) if request.json else {}
except Exception as json_err:
logger.error(f"Invalid JSON in request body: {json_err}")
response.status = 400
return {"error": "Invalid JSON in request body"}
2026-06-15 22:07:34 +02:00
# Resolve provider display label if possible.
2026-01-20 15:26:41 +01:00
provider_label = provider
try:
2026-06-15 22:07:34 +02:00
inst = manager.get_provider(provider)
if inst:
provider_label = getattr(inst, "provider_label", provider)
except Exception:
2026-01-20 15:26:41 +01:00
pass
2026-06-15 22:07:34 +02:00
full_mapping: dict = {"_provider_name": provider_label}
for channel_id, value in mapping_data.items():
if isinstance(value, dict):
full_mapping[channel_id] = value
elif isinstance(value, str):
full_mapping[channel_id] = {"epg_id": value, "name": ""}
2026-01-20 15:26:41 +01:00
vfs = VFS(addon_subdir="")
mapping_file = f"{provider}_epg_mapping.json"
success = vfs.write_json(mapping_file, full_mapping)
2026-06-15 22:07:34 +02:00
if not success:
2026-01-20 15:26:41 +01:00
response.status = 500
return {"error": "Failed to save mapping file"}
2026-06-15 22:07:34 +02:00
logger.info(f"Saved EPG mapping for {provider}: {len(mapping_data)} channels")
2026-06-15 16:46:13 +02:00
2026-06-15 22:07:34 +02:00
# Best-effort cache invalidation.
try:
from streaming_providers.base.epg.epg_mapping import EPGMapping
EPGMapping().reload_mapping(provider)
logger.info(f"Reloaded EPG mapping cache for {provider}")
except ImportError:
logger.debug("EPGMapping not available for cache reload")
except Exception as reload_err:
logger.warning(f"Could not reload mapping cache: {reload_err}")
2026-04-04 09:56:40 +02:00
return {
2026-06-15 22:07:34 +02:00
"success": True,
"message": f"Mapping saved for {provider}",
"channels_mapped": len(mapping_data),
2026-04-04 09:56:40 +02:00
}
except Exception as e:
2026-06-15 22:07:34 +02:00
logger.error(f"Error saving EPG mapping for {provider}: {e}", exc_info=True)
2026-04-04 09:56:40 +02:00
response.status = 500
2026-06-15 22:07:34 +02:00
return {"error": f"Failed to save mapping: {e}"}