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
+89 -2
View File
@@ -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")
+115
View File
@@ -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
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"]
+56 -16
View File
@@ -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",
+11
View File
@@ -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
View File
@@ -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:
+18 -1
View File
@@ -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")
+235 -65
View File
@@ -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 && (