feat: multi-domain failover for provider accounts

Each ProviderAccount can now have multiple base URLs (provider_urls table).
On stream failure, BroadcastGroup cycles to the next domain immediately
with no wait; backs off only after all domains have been tried once.
Background health checker pings every domain every 5 min via player_api.php
and updates status/response_ms. Admin UI shows domain list with color-coded
status badges and a "Verificar todos" button per provider.
This commit is contained in:
joaquin
2026-05-18 00:11:06 +02:00
parent 3a450214ab
commit cccdf9f137
8 changed files with 567 additions and 86 deletions
+39 -1
View File
@@ -112,9 +112,10 @@ class ProviderPool:
provider_name = next(
(pm.get("provider_name", "") for pm in provider_maps if pm["provider_account_id"] == account_id), ""
)
stream_urls = await self._build_stream_urls(account_id, stream_url)
group = BroadcastGroup(
channel_id=channel_id,
stream_url=stream_url,
stream_urls=stream_urls,
provider_account_id=account_id,
channel_name=channel_name,
provider_name=provider_name,
@@ -239,6 +240,43 @@ class ProviderPool:
rest = [pm for pm in provider_maps if pm["provider_account_id"] not in pref_set]
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."""
from urllib.parse import urlparse, urlunparse
from ..models.provider import ProviderUrl
from ..database import AsyncSessionLocal
from sqlalchemy import select
parsed = urlparse(primary_stream_url)
async with AsyncSessionLocal() as db:
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 not provider_urls:
return [primary_stream_url]
urls = []
for pu in provider_urls:
alt = urlparse(pu.url.rstrip('/'))
reconstructed = urlunparse((
alt.scheme or parsed.scheme,
alt.netloc,
parsed.path,
parsed.params,
parsed.query,
parsed.fragment,
))
urls.append(reconstructed)
return urls
async def _find_free_slot(self, provider_maps: list[dict]) -> tuple[int | None, str | None]:
for pm in provider_maps:
account_id = pm["provider_account_id"]