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:
+31
-2
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user