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")
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Background health checker for provider URLs."""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..database import AsyncSessionLocal
|
||||
from ..models.provider import ProviderAccount, ProviderUrl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL = 300 # 5 minutes
|
||||
|
||||
|
||||
async def _check_url(base_url: str, username: str, password: str) -> tuple[str, int | None]:
|
||||
url = f"{base_url.rstrip('/')}/player_api.php"
|
||||
params = {"username": username, "password": password, "action": "user_info"}
|
||||
start = time.time()
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=8)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, params=params, ssl=False) as resp:
|
||||
elapsed_ms = int((time.time() - start) * 1000)
|
||||
if resp.status == 200:
|
||||
try:
|
||||
data = await resp.json(content_type=None)
|
||||
if "user_info" in data:
|
||||
return "ok", elapsed_ms
|
||||
except Exception:
|
||||
pass
|
||||
return "error", elapsed_ms
|
||||
except asyncio.TimeoutError:
|
||||
return "timeout", None
|
||||
except Exception:
|
||||
return "error", None
|
||||
|
||||
|
||||
async def check_provider_urls(provider_account_id: int) -> list[dict]:
|
||||
"""Check all URLs for a specific provider. Returns list of result dicts."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password)
|
||||
.join(ProviderAccount)
|
||||
.where(
|
||||
ProviderUrl.provider_account_id == provider_account_id,
|
||||
ProviderUrl.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
tasks = [_check_url(url, username, password) for _, url, username, password in rows]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
output = []
|
||||
async with AsyncSessionLocal() as db:
|
||||
for (url_id, url, _, _), check_result in zip(rows, results):
|
||||
if isinstance(check_result, Exception):
|
||||
status, response_ms = "error", None
|
||||
else:
|
||||
status, response_ms = check_result
|
||||
pu = await db.get(ProviderUrl, url_id)
|
||||
if pu:
|
||||
pu.status = status
|
||||
pu.response_ms = response_ms
|
||||
pu.last_checked_at = now
|
||||
output.append({"id": url_id, "url": url, "status": status, "response_ms": response_ms})
|
||||
await db.commit()
|
||||
|
||||
return output
|
||||
|
||||
|
||||
async def health_check_loop() -> None:
|
||||
"""Runs forever, checking all active provider URLs every CHECK_INTERVAL seconds."""
|
||||
logger.info("Provider URL health checker started")
|
||||
while True:
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password)
|
||||
.join(ProviderAccount)
|
||||
.where(ProviderUrl.is_active == True) # noqa: E712
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
tasks = [_check_url(url, username, password) for _, url, username, password in rows]
|
||||
check_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
async with AsyncSessionLocal() as db:
|
||||
for (url_id, _, _, _), check_result in zip(rows, check_results):
|
||||
if isinstance(check_result, Exception):
|
||||
status, response_ms = "error", None
|
||||
else:
|
||||
status, response_ms = check_result
|
||||
pu = await db.get(ProviderUrl, url_id)
|
||||
if pu:
|
||||
pu.status = status
|
||||
pu.response_ms = response_ms
|
||||
pu.last_checked_at = now
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Health check done: {len(rows)} URLs checked")
|
||||
except Exception as e:
|
||||
logger.error(f"Health check loop error: {e}")
|
||||
+39
-1
@@ -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"]
|
||||
|
||||
+56
-16
@@ -51,6 +51,14 @@ _SENTINEL = object() # signals end-of-stream to client queues
|
||||
_FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
||||
|
||||
|
||||
def _url_netloc(url: str) -> str:
|
||||
from urllib.parse import urlparse
|
||||
try:
|
||||
return urlparse(url).netloc
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientHandle:
|
||||
client_id: str
|
||||
@@ -75,14 +83,24 @@ class BroadcastGroup:
|
||||
def __init__(
|
||||
self,
|
||||
channel_id: int,
|
||||
stream_url: str,
|
||||
stream_urls: list[str],
|
||||
provider_account_id: int,
|
||||
channel_name: str = "",
|
||||
provider_name: str = "",
|
||||
# kept for backward compat in callers that still pass stream_url as kwarg
|
||||
stream_url: str | None = None,
|
||||
):
|
||||
self.channel_id = channel_id
|
||||
self.channel_name = channel_name
|
||||
self.stream_url = stream_url
|
||||
# Support old callers that pass a single stream_url
|
||||
if stream_urls:
|
||||
self._stream_urls = stream_urls
|
||||
elif stream_url:
|
||||
self._stream_urls = [stream_url]
|
||||
else:
|
||||
self._stream_urls = []
|
||||
self.stream_url = self._stream_urls[0] if self._stream_urls else ""
|
||||
self._active_url: str = self.stream_url
|
||||
self.provider_account_id = provider_account_id
|
||||
self.provider_name = provider_name
|
||||
self.started_at = time.time()
|
||||
@@ -212,6 +230,9 @@ class BroadcastGroup:
|
||||
# ffmpeg process info
|
||||
"ffmpeg_pid": self._ffmpeg_pid,
|
||||
"ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 2),
|
||||
# multi-domain failover info
|
||||
"active_url_domain": _url_netloc(self._active_url),
|
||||
"url_count": len(self._stream_urls),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -256,11 +277,16 @@ class BroadcastGroup:
|
||||
async def _pump_with_retry(self) -> None:
|
||||
consecutive = 0
|
||||
base_wait = 0.5
|
||||
url_idx = 0
|
||||
n = len(self._stream_urls) or 1
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
current_url = self._stream_urls[url_idx % n]
|
||||
self._active_url = current_url
|
||||
try:
|
||||
await self._pump_once()
|
||||
await self._pump_once(current_url)
|
||||
url_idx = 0 # reset to primary after successful pump
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
@@ -269,18 +295,31 @@ class BroadcastGroup:
|
||||
break
|
||||
consecutive += 1
|
||||
self._reconnect_count += 1
|
||||
wait = min(base_wait * (2 ** (consecutive - 1)), _BACKOFF_CAP)
|
||||
next_idx = (url_idx + 1) % n
|
||||
cycle_done = (consecutive % n == 0)
|
||||
|
||||
if n > 1:
|
||||
logger.warning(
|
||||
f"[ch{self.channel_id}] Domain {_url_netloc(current_url)} failed "
|
||||
f"(attempt {consecutive}), trying {_url_netloc(self._stream_urls[next_idx])}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"[ch{self.channel_id}] Upstream error (attempt {consecutive}): {e}"
|
||||
)
|
||||
|
||||
url_idx = next_idx
|
||||
self._status = "reconnecting"
|
||||
logger.warning(
|
||||
f"[ch{self.channel_id}] ffmpeg exited (attempt {consecutive}), "
|
||||
f"restarting in {wait:.1f}s: {e}"
|
||||
)
|
||||
# Drain queues so stale buffered chunks are not delivered
|
||||
# after the fresh ffmpeg process starts.
|
||||
async with self._lock:
|
||||
for handle in self._clients.values():
|
||||
_drain_queue(handle.queue)
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
if cycle_done:
|
||||
# All domains tried — drain queues and back off before next cycle
|
||||
wait = min(base_wait * (2 ** (consecutive // n - 1)), _BACKOFF_CAP)
|
||||
logger.warning(f"[ch{self.channel_id}] All {n} domain(s) failed, retrying in {wait:.1f}s")
|
||||
async with self._lock:
|
||||
for handle in self._clients.values():
|
||||
_drain_queue(handle.queue)
|
||||
await asyncio.sleep(wait)
|
||||
# else: try next domain immediately (no sleep)
|
||||
finally:
|
||||
self._status = "stopped"
|
||||
self._running = False
|
||||
@@ -292,7 +331,7 @@ class BroadcastGroup:
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
async def _pump_once(self) -> None:
|
||||
async def _pump_once(self, url: str | None = None) -> None:
|
||||
"""
|
||||
Spawn ffmpeg in copy (remux) mode and pump its stdout to all client
|
||||
queues until ffmpeg exits or self._running goes False.
|
||||
@@ -302,6 +341,7 @@ class BroadcastGroup:
|
||||
to clients. PTS/DTS values are normalised by ffmpeg across reconnects,
|
||||
eliminating A/V desync entirely.
|
||||
"""
|
||||
stream_url = url if url is not None else self.stream_url
|
||||
cmd = [
|
||||
_FFMPEG,
|
||||
"-hide_banner", "-loglevel", "warning",
|
||||
@@ -316,7 +356,7 @@ class BroadcastGroup:
|
||||
# stops sending bytes for STALL_TIMEOUT seconds ffmpeg closes
|
||||
# the connection and tries to reconnect (or exits if it gives up).
|
||||
"-timeout", str(int(STALL_TIMEOUT * 1_000_000)),
|
||||
"-i", self.stream_url,
|
||||
"-i", stream_url,
|
||||
"-c", "copy", # remux only — zero transcoding, bit-for-bit quality
|
||||
"-f", "mpegts",
|
||||
"pipe:1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy import text
|
||||
from .config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
@@ -27,5 +28,15 @@ async def get_db():
|
||||
|
||||
async def init_db():
|
||||
from .models import provider, channel, user, epg, custom_category, log, notification, jellyfin # noqa: ensure all models imported
|
||||
from .models.provider import ProviderUrl # noqa: ensure ProviderUrl is registered
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Migrate: create provider_urls entries from existing base_url if not already done
|
||||
await conn.execute(text("""
|
||||
INSERT INTO provider_urls (provider_account_id, url, priority, is_active, status)
|
||||
SELECT id, base_url, 0, true, 'unknown'
|
||||
FROM provider_accounts
|
||||
WHERE id NOT IN (SELECT DISTINCT provider_account_id FROM provider_urls WHERE provider_account_id IS NOT NULL)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""))
|
||||
await conn.commit()
|
||||
|
||||
+4
-1
@@ -14,6 +14,7 @@ from .config import settings
|
||||
from .database import init_db, AsyncSessionLocal
|
||||
from .core.pool import pool
|
||||
from .core.epg_manager import epg_background_task
|
||||
from .core.health_checker import health_check_loop
|
||||
from .database import get_db
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -67,13 +68,15 @@ async def lifespan(app: FastAPI):
|
||||
await pool.init()
|
||||
epg_task = asyncio.create_task(epg_background_task(get_db))
|
||||
auto_sync_task = asyncio.create_task(_auto_sync_loop())
|
||||
health_task = asyncio.create_task(health_check_loop())
|
||||
logger.info("KiraStream ready")
|
||||
yield
|
||||
logger.info("KiraStream shutting down...")
|
||||
await pool.close()
|
||||
epg_task.cancel()
|
||||
auto_sync_task.cancel()
|
||||
for t in (epg_task, auto_sync_task):
|
||||
health_task.cancel()
|
||||
for t in (epg_task, auto_sync_task, health_task):
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, func
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from ..database import Base
|
||||
|
||||
@@ -22,3 +22,20 @@ class ProviderAccount(Base):
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
channel_maps = relationship("ChannelProviderMap", back_populates="provider_account", cascade="all, delete-orphan")
|
||||
extra_urls = relationship("ProviderUrl", back_populates="provider_account", cascade="all, delete-orphan", order_by="ProviderUrl.priority")
|
||||
|
||||
|
||||
class ProviderUrl(Base):
|
||||
__tablename__ = "provider_urls"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
provider_account_id = Column(Integer, ForeignKey("provider_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
url = Column(String(500), nullable=False)
|
||||
priority = Column(Integer, default=0, nullable=False) # 0=primary, higher=lower priority
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
status = Column(String(20), default="unknown", nullable=False) # unknown|ok|error|timeout
|
||||
response_ms = Column(Integer, nullable=True)
|
||||
last_checked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
provider_account = relationship("ProviderAccount", back_populates="extra_urls")
|
||||
|
||||
@@ -2,6 +2,16 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Trash2, RefreshCw, Wifi, Search, ChevronDown, ChevronRight, Bell, Edit2 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface ProviderUrl {
|
||||
id: number
|
||||
url: string
|
||||
priority: number
|
||||
is_active: boolean
|
||||
status: 'unknown' | 'ok' | 'error' | 'timeout'
|
||||
response_ms: number | null
|
||||
last_checked_at: string | null
|
||||
}
|
||||
|
||||
interface Provider {
|
||||
id: number
|
||||
name: string
|
||||
@@ -13,6 +23,7 @@ interface Provider {
|
||||
expiry_date: string | null
|
||||
auto_sync_hours: number
|
||||
last_sync_categories: string | null // JSON
|
||||
urls: ProviderUrl[]
|
||||
}
|
||||
|
||||
function expiryBadge(dateStr: string | null): { label: string; cls: string } | null {
|
||||
@@ -89,6 +100,12 @@ export default function Providers() {
|
||||
const [updatesSearch, setUpdatesSearch] = useState('')
|
||||
const [updatesCount, setUpdatesCount] = useState<Record<number, number>>({})
|
||||
|
||||
// URL management state
|
||||
const [expandedUrls, setExpandedUrls] = useState<Set<number>>(new Set())
|
||||
const [addingUrl, setAddingUrl] = useState<number | null>(null)
|
||||
const [newUrlForm, setNewUrlForm] = useState({ url: '', priority: 10, is_active: true })
|
||||
const [checkingUrls, setCheckingUrls] = useState<Set<number>>(new Set())
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/providers/')
|
||||
setProviders(data)
|
||||
@@ -319,6 +336,54 @@ export default function Providers() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUrlSection(id: number) {
|
||||
setExpandedUrls(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
async function addUrl(providerId: number) {
|
||||
await api.post(`/providers/${providerId}/urls`, newUrlForm)
|
||||
setAddingUrl(null)
|
||||
setNewUrlForm({ url: '', priority: 10, is_active: true })
|
||||
load()
|
||||
}
|
||||
|
||||
async function deleteUrl(providerId: number, urlId: number) {
|
||||
if (!confirm('¿Eliminar esta URL de failover?')) return
|
||||
await api.delete(`/providers/${providerId}/urls/${urlId}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function checkAllUrls(providerId: number) {
|
||||
setCheckingUrls(c => new Set(c).add(providerId))
|
||||
try {
|
||||
await api.post(`/providers/${providerId}/urls/check-all`)
|
||||
load()
|
||||
} finally {
|
||||
setCheckingUrls(c => { const n = new Set(c); n.delete(providerId); return n })
|
||||
}
|
||||
}
|
||||
|
||||
function urlStatusBadge(url: ProviderUrl) {
|
||||
if (url.status === 'ok') {
|
||||
return (
|
||||
<span className="text-green-400 text-xs font-mono">
|
||||
● {url.response_ms != null ? `${url.response_ms}ms` : 'OK'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (url.status === 'error') {
|
||||
return <span className="text-red-400 text-xs font-mono">● Error</span>
|
||||
}
|
||||
if (url.status === 'timeout') {
|
||||
return <span className="text-yellow-400 text-xs font-mono">● Timeout</span>
|
||||
}
|
||||
return <span className="text-gray-500 text-xs font-mono">○ Desconocido</span>
|
||||
}
|
||||
|
||||
const totalSelected = Object.values(selectedCats).reduce((a, s) => a + s.size, 0)
|
||||
const totalAvailable = Object.values(availableCats).reduce((a, arr) => a + arr.length, 0)
|
||||
|
||||
@@ -628,77 +693,182 @@ export default function Providers() {
|
||||
|
||||
<div className="space-y-3">
|
||||
{providers.map(p => (
|
||||
<div key={p.id} className="bg-dark-800 border border-dark-700 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{p.name}</span>
|
||||
{p.auto_sync_hours > 0 && (
|
||||
<span className="text-xs bg-dark-700 text-gray-400 px-1.5 py-0.5 rounded">
|
||||
Auto {p.auto_sync_hours}h
|
||||
</span>
|
||||
<div key={p.id} className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden">
|
||||
<div className="p-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{p.name}</span>
|
||||
{p.auto_sync_hours > 0 && (
|
||||
<span className="text-xs bg-dark-700 text-gray-400 px-1.5 py-0.5 rounded">
|
||||
Auto {p.auto_sync_hours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-0.5">
|
||||
<span className="text-xs text-gray-500 truncate">{p.base_url} • {p.username} • {p.max_connections} conex.</span>
|
||||
{(() => { const b = expiryBadge(p.expiry_date); return b ? <span className={`text-xs px-1.5 py-0.5 rounded ${b.cls}`}>{b.label}</span> : null })()}
|
||||
</div>
|
||||
{p.last_sync_at && <div className="text-xs text-gray-600 mt-0.5">Último sync: {new Date(p.last_sync_at).toLocaleString('es-ES')}</div>}
|
||||
{testResult[p.id] !== undefined && (
|
||||
<div className={`text-xs mt-0.5 ${testResult[p.id] ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{testResult[p.id] ? '✓ Conexión OK' : '✗ Sin conexión'}
|
||||
</div>
|
||||
)}
|
||||
{syncProgress[p.id] && (
|
||||
<div className={`text-xs mt-1 ${syncProgress[p.id].startsWith('✓') ? 'text-green-400' : syncProgress[p.id].startsWith('Error') ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{syncing.has(p.id) && <RefreshCw size={10} className="inline animate-spin mr-1" />}
|
||||
{syncProgress[p.id]}
|
||||
{syncStats[p.id] && syncing.has(p.id) && (
|
||||
<span className="text-gray-500 ml-1">
|
||||
({Object.entries(syncStats[p.id]).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-0.5">
|
||||
<span className="text-xs text-gray-500 truncate">{p.base_url} • {p.username} • {p.max_connections} conex.</span>
|
||||
{(() => { const b = expiryBadge(p.expiry_date); return b ? <span className={`text-xs px-1.5 py-0.5 rounded ${b.cls}`}>{b.label}</span> : null })()}
|
||||
</div>
|
||||
{p.last_sync_at && <div className="text-xs text-gray-600 mt-0.5">Último sync: {new Date(p.last_sync_at).toLocaleString('es-ES')}</div>}
|
||||
{testResult[p.id] !== undefined && (
|
||||
<div className={`text-xs mt-0.5 ${testResult[p.id] ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{testResult[p.id] ? '✓ Conexión OK' : '✗ Sin conexión'}
|
||||
</div>
|
||||
)}
|
||||
{syncProgress[p.id] && (
|
||||
<div className={`text-xs mt-1 ${syncProgress[p.id].startsWith('✓') ? 'text-green-400' : syncProgress[p.id].startsWith('Error') ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{syncing.has(p.id) && <RefreshCw size={10} className="inline animate-spin mr-1" />}
|
||||
{syncProgress[p.id]}
|
||||
{syncStats[p.id] && syncing.has(p.id) && (
|
||||
<span className="text-gray-500 ml-1">
|
||||
({Object.entries(syncStats[p.id]).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => test(p.id)}
|
||||
disabled={testing === p.id}
|
||||
title="Probar conexión"
|
||||
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{testing === p.id ? <RefreshCw size={15} className="animate-spin" /> : <Wifi size={15} />}
|
||||
</button>
|
||||
{p.last_sync_at && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => openUpdates(p)}
|
||||
title="Ver novedades del proveedor"
|
||||
className="relative p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors"
|
||||
onClick={() => test(p.id)}
|
||||
disabled={testing === p.id}
|
||||
title="Probar conexión"
|
||||
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{(updatesCount[p.id] ?? 0) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{updatesCount[p.id] > 9 ? '9+' : updatesCount[p.id]}
|
||||
</span>
|
||||
)}
|
||||
{testing === p.id ? <RefreshCw size={15} className="animate-spin" /> : <Wifi size={15} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openCategoryPicker(p)}
|
||||
disabled={syncing.has(p.id)}
|
||||
title="Sincronizar canales"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 text-xs text-gray-300 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={13} className={syncing.has(p.id) ? 'animate-spin' : ''} />
|
||||
{syncing.has(p.id) ? 'Sincronizando...' : 'Sync canales'}
|
||||
</button>
|
||||
<button onClick={() => openEdit(p)} title="Editar proveedor" className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors">
|
||||
<Edit2 size={15} />
|
||||
</button>
|
||||
<button onClick={() => del(p.id)} className="p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
{p.last_sync_at && (
|
||||
<button
|
||||
onClick={() => openUpdates(p)}
|
||||
title="Ver novedades del proveedor"
|
||||
className="relative p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{(updatesCount[p.id] ?? 0) > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{updatesCount[p.id] > 9 ? '9+' : updatesCount[p.id]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openCategoryPicker(p)}
|
||||
disabled={syncing.has(p.id)}
|
||||
title="Sincronizar canales"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 hover:bg-dark-600 text-xs text-gray-300 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={13} className={syncing.has(p.id) ? 'animate-spin' : ''} />
|
||||
{syncing.has(p.id) ? 'Sincronizando...' : 'Sync canales'}
|
||||
</button>
|
||||
<button onClick={() => openEdit(p)} title="Editar proveedor" className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors">
|
||||
<Edit2 size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleUrlSection(p.id)}
|
||||
title="Gestionar dominios de failover"
|
||||
className={`p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors ${expandedUrls.has(p.id) ? 'text-primary-400' : 'text-gray-400 hover:text-primary-400'}`}
|
||||
>
|
||||
{expandedUrls.has(p.id) ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
<button onClick={() => del(p.id)} className="p-2 rounded-lg bg-dark-700 hover:bg-red-900/40 text-gray-500 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL management section */}
|
||||
{expandedUrls.has(p.id) && (
|
||||
<div className="border-t border-dark-700 bg-dark-900/40 p-4 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Dominios (failover)</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => checkAllUrls(p.id)}
|
||||
disabled={checkingUrls.has(p.id)}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-blue-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={11} className={checkingUrls.has(p.id) ? 'animate-spin' : ''} />
|
||||
Verificar todos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setAddingUrl(p.id); setNewUrlForm({ url: '', priority: 10, is_active: true }) }}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-green-400 transition-colors"
|
||||
>
|
||||
<Plus size={11} />
|
||||
Añadir URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(p.urls ?? []).length === 0 && addingUrl !== p.id && (
|
||||
<div className="text-xs text-gray-600 text-center py-2">No hay URLs adicionales configuradas</div>
|
||||
)}
|
||||
|
||||
{(p.urls ?? []).map(u => (
|
||||
<div key={u.id} className="flex items-center gap-3 bg-dark-800 rounded-lg px-3 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-300 truncate">{u.url}</span>
|
||||
{u.priority === 0 && <span className="text-[10px] bg-primary-900/40 text-primary-400 px-1 rounded">primaria</span>}
|
||||
{!u.is_active && <span className="text-[10px] bg-dark-600 text-gray-500 px-1 rounded">inactiva</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{urlStatusBadge(u)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteUrl(p.id, u.id)}
|
||||
className="p-1 rounded text-gray-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{addingUrl === p.id && (
|
||||
<div className="bg-dark-800 rounded-lg p-3 space-y-2 border border-dark-600">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[10px] text-gray-500 mb-1">URL base (ej: http://mirror.com:8080)</label>
|
||||
<input
|
||||
value={newUrlForm.url}
|
||||
onChange={e => setNewUrlForm(f => ({ ...f, url: e.target.value }))}
|
||||
placeholder="http://..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-500 mb-1">Prioridad (0=más alta)</label>
|
||||
<input
|
||||
type="number" min={0} max={999}
|
||||
value={newUrlForm.priority}
|
||||
onChange={e => setNewUrlForm(f => ({ ...f, priority: +e.target.value }))}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newUrlForm.is_active}
|
||||
onChange={e => setNewUrlForm(f => ({ ...f, is_active: e.target.checked }))}
|
||||
/>
|
||||
Activa
|
||||
</label>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button onClick={() => setAddingUrl(null)} className="text-xs px-2 py-1 rounded bg-dark-700 hover:bg-dark-600 text-gray-400">Cancelar</button>
|
||||
<button
|
||||
onClick={() => addUrl(p.id)}
|
||||
disabled={!newUrlForm.url}
|
||||
className="text-xs px-2 py-1 rounded bg-primary-500 hover:bg-primary-600 disabled:opacity-40 font-medium"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{providers.length === 0 && (
|
||||
|
||||
Reference in New Issue
Block a user