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:
@@ -8,8 +8,9 @@ from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sqlalchemy.orm import selectinload
|
||||
from ...database import get_db, AsyncSessionLocal
|
||||
from ...models.provider import ProviderAccount
|
||||
from ...models.provider import ProviderAccount, ProviderUrl
|
||||
from ...models.channel import Category, Channel, ChannelProviderMap, ContentType
|
||||
from ...models.notification import NotificationDismissal
|
||||
from ...models.epg import EpgSource
|
||||
@@ -42,6 +43,19 @@ class ProviderUpdate(BaseModel):
|
||||
auto_sync_hours: int = 0
|
||||
|
||||
|
||||
class ProviderUrlOut(BaseModel):
|
||||
id: int
|
||||
url: str
|
||||
priority: int
|
||||
is_active: bool
|
||||
status: str
|
||||
response_ms: int | None = None
|
||||
last_checked_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProviderOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
@@ -53,6 +67,7 @@ class ProviderOut(BaseModel):
|
||||
expiry_date: datetime | None = None
|
||||
auto_sync_hours: int
|
||||
last_sync_categories: str | None # raw JSON, parsed on frontend
|
||||
urls: list[ProviderUrlOut] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -78,10 +93,15 @@ def _jellyfin_provider_subq():
|
||||
async def list_providers(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(ProviderAccount)
|
||||
.options(selectinload(ProviderAccount.extra_urls))
|
||||
.where(ProviderAccount.id.notin_(_jellyfin_provider_subq()))
|
||||
.order_by(ProviderAccount.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
providers = result.scalars().all()
|
||||
# Map extra_urls to the urls field expected by ProviderOut
|
||||
for p in providers:
|
||||
p.urls = p.extra_urls
|
||||
return providers
|
||||
|
||||
|
||||
async def _sync_epg_source(db: AsyncSession, provider: ProviderAccount) -> None:
|
||||
@@ -629,6 +649,73 @@ async def _run_sync(
|
||||
_sync_status[provider_id] = {"status": "error", "progress": str(e), "stats": stats}
|
||||
|
||||
|
||||
# ── Provider URL management ────────────────────────────────────────────
|
||||
|
||||
class ProviderUrlCreate(BaseModel):
|
||||
url: str
|
||||
priority: int = 10
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@router.get("/{provider_id}/urls", response_model=list[ProviderUrlOut])
|
||||
async def list_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(ProviderUrl)
|
||||
.where(ProviderUrl.provider_account_id == provider_id)
|
||||
.order_by(ProviderUrl.priority)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{provider_id}/urls", response_model=ProviderUrlOut)
|
||||
async def add_provider_url(provider_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
pu = ProviderUrl(provider_account_id=provider_id, **data.model_dump())
|
||||
db.add(pu)
|
||||
await db.commit()
|
||||
await db.refresh(pu)
|
||||
return pu
|
||||
|
||||
|
||||
@router.put("/{provider_id}/urls/{url_id}", response_model=ProviderUrlOut)
|
||||
async def update_provider_url(provider_id: int, url_id: int, data: ProviderUrlCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id)
|
||||
)
|
||||
pu = result.scalar_one_or_none()
|
||||
if not pu:
|
||||
raise HTTPException(404, "URL not found")
|
||||
pu.url = data.url
|
||||
pu.priority = data.priority
|
||||
pu.is_active = data.is_active
|
||||
await db.commit()
|
||||
await db.refresh(pu)
|
||||
return pu
|
||||
|
||||
|
||||
@router.delete("/{provider_id}/urls/{url_id}")
|
||||
async def delete_provider_url(provider_id: int, url_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
result = await db.execute(
|
||||
select(ProviderUrl).where(ProviderUrl.id == url_id, ProviderUrl.provider_account_id == provider_id)
|
||||
)
|
||||
pu = result.scalar_one_or_none()
|
||||
if not pu:
|
||||
raise HTTPException(404, "URL not found")
|
||||
await db.delete(pu)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{provider_id}/urls/check-all")
|
||||
async def check_all_provider_urls(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
from ...core.health_checker import check_provider_urls
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(404, "Provider not found")
|
||||
results = await check_provider_urls(provider_id)
|
||||
return results
|
||||
|
||||
|
||||
async def _upsert_category(db: AsyncSession, cat_data: dict, ctype: ContentType, provider_id: int) -> Category:
|
||||
provider_cat_id = str(cat_data.get("category_id", ""))
|
||||
cat_name = cat_data.get("category_name", "Unknown")
|
||||
|
||||
Reference in New Issue
Block a user