feat: domain health check automático con stickiness de dominio

- health_checker: usa TCP connect en lugar de Xtream API para dominios CDN
  (correcto para dominios que solo sirven streams, no la API de credenciales)
  Intervalo reducido a 3 min; detecta recuperación automática y lo registra en log
- pool: _build_stream_urls excluye dominios con status='error'; si todos están caídos
  usa todos como fallback para no dejar el stream sin opciones
- restream: stickiness de dominio — en renovación de sesión normal reconecta al mismo
  dominio siempre; solo rota al siguiente en fallos reales (<25s)
- restream: _mark_domain_down marca el dominio como error en BD inmediatamente al
  detectar un fallo rápido, sin esperar al próximo ciclo del health checker
- database: usa ADD COLUMN IF NOT EXISTS para la migración (evita abortar transacción)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
KiraStream
2026-05-19 15:42:37 +00:00
parent 4bfc7592a6
commit 99155271bb
4 changed files with 189 additions and 69 deletions
+31 -2
View File
@@ -241,7 +241,13 @@ class ProviderPool:
return preferred + rest
async def _build_stream_urls(self, account_id: int, primary_stream_url: str) -> list[str]:
"""Build list of stream URLs for failover: one per active ProviderUrl, substituting base URL."""
"""Build list of stream URLs for multi-domain failover.
Only includes domains with status != 'error' (healthy or not-yet-checked).
If ALL domains are currently marked 'error', falls back to using all of them
so the stream isn't stranded — the health checker will restore them when
reachable again.
"""
from urllib.parse import urlparse, urlunparse
from ..models.provider import ProviderUrl
from ..database import AsyncSessionLocal
@@ -250,19 +256,39 @@ class ProviderPool:
parsed = urlparse(primary_stream_url)
async with AsyncSessionLocal() as db:
# Prefer healthy/unknown domains only
result = await db.execute(
select(ProviderUrl)
.where(
ProviderUrl.provider_account_id == account_id,
ProviderUrl.is_active == True, # noqa: E712
ProviderUrl.is_active == True, # noqa: E712
ProviderUrl.status != "error",
)
.order_by(ProviderUrl.priority)
)
provider_urls = result.scalars().all()
if not provider_urls:
# Fallback: all active domains even if all are 'error'
result = await db.execute(
select(ProviderUrl)
.where(
ProviderUrl.provider_account_id == account_id,
ProviderUrl.is_active == True, # noqa: E712
)
.order_by(ProviderUrl.priority)
)
provider_urls = result.scalars().all()
if provider_urls:
logger.warning(
f"provider {account_id}: all domains marked 'error' — "
f"using all {len(provider_urls)} as fallback"
)
if not provider_urls:
return [primary_stream_url]
skipped = []
urls = []
for pu in provider_urls:
alt = urlparse(pu.url.rstrip('/'))
@@ -275,6 +301,9 @@ class ProviderPool:
parsed.fragment,
))
urls.append(reconstructed)
if skipped:
logger.debug(f"provider {account_id}: skipped error domains {skipped}")
return urls
async def _find_free_slot(self, provider_maps: list[dict]) -> tuple[int | None, str | None]: