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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from ...database import get_db, AsyncSessionLocal
|
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.channel import Category, Channel, ChannelProviderMap, ContentType
|
||||||
from ...models.notification import NotificationDismissal
|
from ...models.notification import NotificationDismissal
|
||||||
from ...models.epg import EpgSource
|
from ...models.epg import EpgSource
|
||||||
@@ -42,6 +43,19 @@ class ProviderUpdate(BaseModel):
|
|||||||
auto_sync_hours: int = 0
|
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):
|
class ProviderOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
@@ -53,6 +67,7 @@ class ProviderOut(BaseModel):
|
|||||||
expiry_date: datetime | None = None
|
expiry_date: datetime | None = None
|
||||||
auto_sync_hours: int
|
auto_sync_hours: int
|
||||||
last_sync_categories: str | None # raw JSON, parsed on frontend
|
last_sync_categories: str | None # raw JSON, parsed on frontend
|
||||||
|
urls: list[ProviderUrlOut] = []
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -78,10 +93,15 @@ def _jellyfin_provider_subq():
|
|||||||
async def list_providers(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
async def list_providers(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProviderAccount)
|
select(ProviderAccount)
|
||||||
|
.options(selectinload(ProviderAccount.extra_urls))
|
||||||
.where(ProviderAccount.id.notin_(_jellyfin_provider_subq()))
|
.where(ProviderAccount.id.notin_(_jellyfin_provider_subq()))
|
||||||
.order_by(ProviderAccount.id)
|
.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:
|
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}
|
_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:
|
async def _upsert_category(db: AsyncSession, cat_data: dict, ctype: ContentType, provider_id: int) -> Category:
|
||||||
provider_cat_id = str(cat_data.get("category_id", ""))
|
provider_cat_id = str(cat_data.get("category_id", ""))
|
||||||
cat_name = cat_data.get("category_name", "Unknown")
|
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(
|
provider_name = next(
|
||||||
(pm.get("provider_name", "") for pm in provider_maps if pm["provider_account_id"] == account_id), ""
|
(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(
|
group = BroadcastGroup(
|
||||||
channel_id=channel_id,
|
channel_id=channel_id,
|
||||||
stream_url=stream_url,
|
stream_urls=stream_urls,
|
||||||
provider_account_id=account_id,
|
provider_account_id=account_id,
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
provider_name=provider_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]
|
rest = [pm for pm in provider_maps if pm["provider_account_id"] not in pref_set]
|
||||||
return preferred + rest
|
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]:
|
async def _find_free_slot(self, provider_maps: list[dict]) -> tuple[int | None, str | None]:
|
||||||
for pm in provider_maps:
|
for pm in provider_maps:
|
||||||
account_id = pm["provider_account_id"]
|
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"
|
_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
|
@dataclass
|
||||||
class ClientHandle:
|
class ClientHandle:
|
||||||
client_id: str
|
client_id: str
|
||||||
@@ -75,14 +83,24 @@ class BroadcastGroup:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
channel_id: int,
|
channel_id: int,
|
||||||
stream_url: str,
|
stream_urls: list[str],
|
||||||
provider_account_id: int,
|
provider_account_id: int,
|
||||||
channel_name: str = "",
|
channel_name: str = "",
|
||||||
provider_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_id = channel_id
|
||||||
self.channel_name = channel_name
|
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_account_id = provider_account_id
|
||||||
self.provider_name = provider_name
|
self.provider_name = provider_name
|
||||||
self.started_at = time.time()
|
self.started_at = time.time()
|
||||||
@@ -212,6 +230,9 @@ class BroadcastGroup:
|
|||||||
# ffmpeg process info
|
# ffmpeg process info
|
||||||
"ffmpeg_pid": self._ffmpeg_pid,
|
"ffmpeg_pid": self._ffmpeg_pid,
|
||||||
"ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 2),
|
"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:
|
async def _pump_with_retry(self) -> None:
|
||||||
consecutive = 0
|
consecutive = 0
|
||||||
base_wait = 0.5
|
base_wait = 0.5
|
||||||
|
url_idx = 0
|
||||||
|
n = len(self._stream_urls) or 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self._running:
|
while self._running:
|
||||||
|
current_url = self._stream_urls[url_idx % n]
|
||||||
|
self._active_url = current_url
|
||||||
try:
|
try:
|
||||||
await self._pump_once()
|
await self._pump_once(current_url)
|
||||||
|
url_idx = 0 # reset to primary after successful pump
|
||||||
break
|
break
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
@@ -269,18 +295,31 @@ class BroadcastGroup:
|
|||||||
break
|
break
|
||||||
consecutive += 1
|
consecutive += 1
|
||||||
self._reconnect_count += 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"
|
self._status = "reconnecting"
|
||||||
logger.warning(
|
|
||||||
f"[ch{self.channel_id}] ffmpeg exited (attempt {consecutive}), "
|
if cycle_done:
|
||||||
f"restarting in {wait:.1f}s: {e}"
|
# All domains tried — drain queues and back off before next cycle
|
||||||
)
|
wait = min(base_wait * (2 ** (consecutive // n - 1)), _BACKOFF_CAP)
|
||||||
# Drain queues so stale buffered chunks are not delivered
|
logger.warning(f"[ch{self.channel_id}] All {n} domain(s) failed, retrying in {wait:.1f}s")
|
||||||
# after the fresh ffmpeg process starts.
|
async with self._lock:
|
||||||
async with self._lock:
|
for handle in self._clients.values():
|
||||||
for handle in self._clients.values():
|
_drain_queue(handle.queue)
|
||||||
_drain_queue(handle.queue)
|
await asyncio.sleep(wait)
|
||||||
await asyncio.sleep(wait)
|
# else: try next domain immediately (no sleep)
|
||||||
finally:
|
finally:
|
||||||
self._status = "stopped"
|
self._status = "stopped"
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -292,7 +331,7 @@ class BroadcastGroup:
|
|||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
pass
|
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
|
Spawn ffmpeg in copy (remux) mode and pump its stdout to all client
|
||||||
queues until ffmpeg exits or self._running goes False.
|
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,
|
to clients. PTS/DTS values are normalised by ffmpeg across reconnects,
|
||||||
eliminating A/V desync entirely.
|
eliminating A/V desync entirely.
|
||||||
"""
|
"""
|
||||||
|
stream_url = url if url is not None else self.stream_url
|
||||||
cmd = [
|
cmd = [
|
||||||
_FFMPEG,
|
_FFMPEG,
|
||||||
"-hide_banner", "-loglevel", "warning",
|
"-hide_banner", "-loglevel", "warning",
|
||||||
@@ -316,7 +356,7 @@ class BroadcastGroup:
|
|||||||
# stops sending bytes for STALL_TIMEOUT seconds ffmpeg closes
|
# stops sending bytes for STALL_TIMEOUT seconds ffmpeg closes
|
||||||
# the connection and tries to reconnect (or exits if it gives up).
|
# the connection and tries to reconnect (or exits if it gives up).
|
||||||
"-timeout", str(int(STALL_TIMEOUT * 1_000_000)),
|
"-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
|
"-c", "copy", # remux only — zero transcoding, bit-for-bit quality
|
||||||
"-f", "mpegts",
|
"-f", "mpegts",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy import text
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
@@ -27,5 +28,15 @@ async def get_db():
|
|||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
from .models import provider, channel, user, epg, custom_category, log, notification, jellyfin # noqa: ensure all models imported
|
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:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
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 .database import init_db, AsyncSessionLocal
|
||||||
from .core.pool import pool
|
from .core.pool import pool
|
||||||
from .core.epg_manager import epg_background_task
|
from .core.epg_manager import epg_background_task
|
||||||
|
from .core.health_checker import health_check_loop
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -67,13 +68,15 @@ async def lifespan(app: FastAPI):
|
|||||||
await pool.init()
|
await pool.init()
|
||||||
epg_task = asyncio.create_task(epg_background_task(get_db))
|
epg_task = asyncio.create_task(epg_background_task(get_db))
|
||||||
auto_sync_task = asyncio.create_task(_auto_sync_loop())
|
auto_sync_task = asyncio.create_task(_auto_sync_loop())
|
||||||
|
health_task = asyncio.create_task(health_check_loop())
|
||||||
logger.info("KiraStream ready")
|
logger.info("KiraStream ready")
|
||||||
yield
|
yield
|
||||||
logger.info("KiraStream shutting down...")
|
logger.info("KiraStream shutting down...")
|
||||||
await pool.close()
|
await pool.close()
|
||||||
epg_task.cancel()
|
epg_task.cancel()
|
||||||
auto_sync_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:
|
try:
|
||||||
await t
|
await t
|
||||||
except asyncio.CancelledError:
|
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 sqlalchemy.orm import relationship
|
||||||
from ..database import Base
|
from ..database import Base
|
||||||
|
|
||||||
@@ -22,3 +22,20 @@ class ProviderAccount(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
channel_maps = relationship("ChannelProviderMap", back_populates="provider_account", cascade="all, delete-orphan")
|
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 { Plus, Trash2, RefreshCw, Wifi, Search, ChevronDown, ChevronRight, Bell, Edit2 } from 'lucide-react'
|
||||||
import api from '../lib/api'
|
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 {
|
interface Provider {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -13,6 +23,7 @@ interface Provider {
|
|||||||
expiry_date: string | null
|
expiry_date: string | null
|
||||||
auto_sync_hours: number
|
auto_sync_hours: number
|
||||||
last_sync_categories: string | null // JSON
|
last_sync_categories: string | null // JSON
|
||||||
|
urls: ProviderUrl[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function expiryBadge(dateStr: string | null): { label: string; cls: string } | null {
|
function expiryBadge(dateStr: string | null): { label: string; cls: string } | null {
|
||||||
@@ -89,6 +100,12 @@ export default function Providers() {
|
|||||||
const [updatesSearch, setUpdatesSearch] = useState('')
|
const [updatesSearch, setUpdatesSearch] = useState('')
|
||||||
const [updatesCount, setUpdatesCount] = useState<Record<number, number>>({})
|
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() {
|
async function load() {
|
||||||
const { data } = await api.get('/providers/')
|
const { data } = await api.get('/providers/')
|
||||||
setProviders(data)
|
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 totalSelected = Object.values(selectedCats).reduce((a, s) => a + s.size, 0)
|
||||||
const totalAvailable = Object.values(availableCats).reduce((a, arr) => a + arr.length, 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">
|
<div className="space-y-3">
|
||||||
{providers.map(p => (
|
{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 key={p.id} className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="p-4 flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="font-semibold">{p.name}</span>
|
<div className="flex items-center gap-2">
|
||||||
{p.auto_sync_hours > 0 && (
|
<span className="font-semibold">{p.name}</span>
|
||||||
<span className="text-xs bg-dark-700 text-gray-400 px-1.5 py-0.5 rounded">
|
{p.auto_sync_hours > 0 && (
|
||||||
Auto {p.auto_sync_hours}h
|
<span className="text-xs bg-dark-700 text-gray-400 px-1.5 py-0.5 rounded">
|
||||||
</span>
|
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>
|
||||||
<div className="flex items-center gap-2 flex-wrap mt-0.5">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<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 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => openUpdates(p)}
|
onClick={() => test(p.id)}
|
||||||
title="Ver novedades del proveedor"
|
disabled={testing === p.id}
|
||||||
className="relative p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors"
|
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} />
|
{testing === p.id ? <RefreshCw size={15} className="animate-spin" /> : <Wifi 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>
|
||||||
)}
|
{p.last_sync_at && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openCategoryPicker(p)}
|
onClick={() => openUpdates(p)}
|
||||||
disabled={syncing.has(p.id)}
|
title="Ver novedades del proveedor"
|
||||||
title="Sincronizar canales"
|
className="relative p-2 rounded-lg bg-dark-700 hover:bg-dark-600 text-gray-400 hover:text-yellow-400 transition-colors"
|
||||||
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"
|
>
|
||||||
>
|
<Bell size={15} />
|
||||||
<RefreshCw size={13} className={syncing.has(p.id) ? 'animate-spin' : ''} />
|
{(updatesCount[p.id] ?? 0) > 0 && (
|
||||||
{syncing.has(p.id) ? 'Sincronizando...' : 'Sync canales'}
|
<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">
|
||||||
</button>
|
{updatesCount[p.id] > 9 ? '9+' : updatesCount[p.id]}
|
||||||
<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">
|
</span>
|
||||||
<Edit2 size={15} />
|
)}
|
||||||
</button>
|
</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
|
||||||
</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>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{providers.length === 0 && (
|
{providers.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user