Mejoras de estabilidad de stream IPTV y nuevo registro de fallos
- restream.py: eliminar -reconnect_streamed de ffmpeg para evitar rebobinados al reconectar (el proveedor re-enviaba desde keyframe anterior causando PTS backward). El loop de Python gestiona las reconexiones con conexión fresca. - restream.py: eliminar _drain_queue del loop de reconexión para que el buffer de la cola cubra el tiempo de reconexión sin pantalla negra en el player. - restream.py: añadir buffer_server_bytes/secs/capacity a stats() y método _log_stream_event() para persistir eventos de caída y recuperación en BD. - models/stream_event.py: nuevo modelo StreamEvent para registro de fallos. - database.py: registrar StreamEvent en init_db. - api/admin/logs.py: nuevos endpoints GET/DELETE /logs/events. - Dashboard.tsx: mostrar reserva de buffer del servidor (capacidad + estado). - Logs.tsx: añadir pestaña "Fallos de stream" con tabla de eventos persistidos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,833 @@
|
||||
"""Admin API: provider account management + sync."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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, ProviderUrl
|
||||
from ...models.channel import Category, Channel, ChannelProviderMap, ContentType
|
||||
from ...models.notification import NotificationDismissal
|
||||
from ...models.epg import EpgSource
|
||||
from ...core.xtream_client import XtreamClient
|
||||
from ...core.epg_manager import refresh_epg
|
||||
from ..auth import get_current_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/providers", tags=["providers"])
|
||||
|
||||
# Track ongoing sync tasks: provider_id -> status dict
|
||||
_sync_status: dict[int, dict] = {}
|
||||
|
||||
|
||||
class ProviderCreate(BaseModel):
|
||||
name: str
|
||||
base_url: str
|
||||
username: str
|
||||
password: str
|
||||
max_connections: int = 1
|
||||
auto_sync_hours: int = 0 # 0=disabled, 6/12/24=interval in hours
|
||||
|
||||
|
||||
class ProviderUpdate(BaseModel):
|
||||
name: str
|
||||
base_url: str
|
||||
username: str
|
||||
password: str | None = None # None = keep existing
|
||||
max_connections: int = 1
|
||||
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
|
||||
base_url: str
|
||||
username: str
|
||||
max_connections: int
|
||||
is_active: bool
|
||||
last_sync_at: datetime | None
|
||||
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
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
live_category_ids: list[str] | None = None # None = import all
|
||||
movie_category_ids: list[str] | None = None
|
||||
series_category_ids: list[str] | None = None
|
||||
|
||||
|
||||
def _jellyfin_provider_subq():
|
||||
"""Subquery returning provider_account_ids used by Jellyfin configs."""
|
||||
from ...models.jellyfin import JellyfinConfig
|
||||
return (
|
||||
select(JellyfinConfig.provider_account_id)
|
||||
.where(JellyfinConfig.provider_account_id.isnot(None))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ProviderOut])
|
||||
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)
|
||||
)
|
||||
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:
|
||||
"""Create or update the auto-managed EPG source for a provider."""
|
||||
url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url()
|
||||
name = f"EPG - {provider.name}"
|
||||
result = await db.execute(select(EpgSource).where(EpgSource.name == name))
|
||||
source = result.scalar_one_or_none()
|
||||
if source:
|
||||
source.url = url
|
||||
else:
|
||||
db.add(EpgSource(name=name, url=url, is_active=True))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _delete_epg_source(db: AsyncSession, provider: ProviderAccount) -> None:
|
||||
name = f"EPG - {provider.name}"
|
||||
result = await db.execute(select(EpgSource).where(EpgSource.name == name))
|
||||
source = result.scalar_one_or_none()
|
||||
if source:
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/", response_model=ProviderOut)
|
||||
async def create_provider(data: ProviderCreate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
provider = ProviderAccount(**data.model_dump())
|
||||
db.add(provider)
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
await _sync_epg_source(db, provider)
|
||||
return provider
|
||||
|
||||
|
||||
@router.put("/{provider_id}", response_model=ProviderOut)
|
||||
async def update_provider(provider_id: int, data: ProviderUpdate, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
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")
|
||||
provider.name = data.name
|
||||
provider.base_url = data.base_url
|
||||
provider.username = data.username
|
||||
provider.max_connections = data.max_connections
|
||||
provider.auto_sync_hours = data.auto_sync_hours
|
||||
if data.password:
|
||||
provider.password = data.password
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
await _sync_epg_source(db, provider)
|
||||
return provider
|
||||
|
||||
|
||||
@router.get("/{provider_id}/synced-category-ids")
|
||||
async def get_synced_category_ids(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Returns the category IDs currently synced in DB for this provider (by content type)."""
|
||||
result = await db.execute(
|
||||
select(Category.provider_category_id, Category.type)
|
||||
.where(Category.provider_account_id == provider_id)
|
||||
)
|
||||
rows = result.all()
|
||||
return {
|
||||
"live": [r.provider_category_id for r in rows if r.type == ContentType.live],
|
||||
"movie": [r.provider_category_id for r in rows if r.type == ContentType.movie],
|
||||
"series": [r.provider_category_id for r in rows if r.type == ContentType.series],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{provider_id}/available-updates")
|
||||
async def get_available_updates(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Return categories from the provider that are NOT yet synced into the DB."""
|
||||
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")
|
||||
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
live_task = asyncio.create_task(client.get_live_categories())
|
||||
vod_task = asyncio.create_task(client.get_vod_categories())
|
||||
series_task = asyncio.create_task(client.get_series_categories())
|
||||
live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True)
|
||||
|
||||
def to_list(raw):
|
||||
if isinstance(raw, Exception):
|
||||
return []
|
||||
return raw if isinstance(raw, list) else []
|
||||
|
||||
# Get all category provider_category_ids already in DB for this provider
|
||||
db_cats = await db.execute(
|
||||
select(Category.provider_category_id, Category.type)
|
||||
.where(Category.provider_account_id == provider_id)
|
||||
)
|
||||
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
|
||||
|
||||
def filter_new(raw, ctype: ContentType):
|
||||
result = []
|
||||
for c in to_list(raw):
|
||||
cat_id = str(c.get("category_id", ""))
|
||||
if not cat_id:
|
||||
continue
|
||||
if (cat_id, ctype) not in synced:
|
||||
result.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value})
|
||||
return result
|
||||
|
||||
new_live = filter_new(live_raw, ContentType.live)
|
||||
new_movie = filter_new(vod_raw, ContentType.movie)
|
||||
new_series = filter_new(series_raw, ContentType.series)
|
||||
|
||||
return {
|
||||
"provider_id": provider_id,
|
||||
"provider_name": provider.name,
|
||||
"new_categories": new_live + new_movie + new_series,
|
||||
"total": len(new_live) + len(new_movie) + len(new_series),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{provider_id}")
|
||||
async def delete_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
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")
|
||||
await _delete_epg_source(db, provider)
|
||||
await db.delete(provider)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/notifications/all")
|
||||
async def get_all_notifications(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Return new (unsynced + undismissed) categories across all providers (Jellyfin excluded)."""
|
||||
providers_result = await db.execute(
|
||||
select(ProviderAccount).where(
|
||||
ProviderAccount.is_active == True, # noqa: E712
|
||||
ProviderAccount.last_sync_at.isnot(None),
|
||||
ProviderAccount.id.notin_(_jellyfin_provider_subq()),
|
||||
)
|
||||
)
|
||||
providers = providers_result.scalars().all()
|
||||
|
||||
# Load all dismissals
|
||||
dismissals_result = await db.execute(select(NotificationDismissal))
|
||||
dismissed: set[tuple] = {
|
||||
(str(d.provider_id), d.category_id_str, d.category_type)
|
||||
for d in dismissals_result.scalars().all()
|
||||
}
|
||||
|
||||
notifications = []
|
||||
for provider in providers:
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
try:
|
||||
live_raw, vod_raw, series_raw = await asyncio.gather(
|
||||
client.get_live_categories(),
|
||||
client.get_vod_categories(),
|
||||
client.get_series_categories(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
db_cats = await db.execute(
|
||||
select(Category.provider_category_id, Category.type)
|
||||
.where(Category.provider_account_id == provider.id)
|
||||
)
|
||||
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
|
||||
|
||||
def collect_new(raw, ctype: ContentType):
|
||||
if isinstance(raw, Exception):
|
||||
return []
|
||||
items = []
|
||||
for c in (raw if isinstance(raw, list) else []):
|
||||
cat_id = str(c.get("category_id", ""))
|
||||
if not cat_id:
|
||||
continue
|
||||
if (cat_id, ctype) in synced:
|
||||
continue
|
||||
if (str(provider.id), cat_id, ctype.value) in dismissed:
|
||||
continue
|
||||
items.append({"id": cat_id, "name": c.get("category_name", ""), "type": ctype.value})
|
||||
return items
|
||||
|
||||
new_cats = (
|
||||
collect_new(live_raw, ContentType.live) +
|
||||
collect_new(vod_raw, ContentType.movie) +
|
||||
collect_new(series_raw, ContentType.series)
|
||||
)
|
||||
if new_cats:
|
||||
notifications.append({
|
||||
"provider_id": provider.id,
|
||||
"provider_name": provider.name,
|
||||
"categories": new_cats,
|
||||
})
|
||||
|
||||
total = sum(len(n["categories"]) for n in notifications)
|
||||
return {"notifications": notifications, "total": total}
|
||||
|
||||
|
||||
@router.get("/notifications/count")
|
||||
async def get_notifications_count(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Fast count of undismissed new categories (checks DB only, no remote API calls)."""
|
||||
providers_result = await db.execute(
|
||||
select(ProviderAccount).where(ProviderAccount.is_active == True, ProviderAccount.last_sync_at.isnot(None)) # noqa: E712
|
||||
)
|
||||
providers = providers_result.scalars().all()
|
||||
|
||||
dismissals_result = await db.execute(select(NotificationDismissal))
|
||||
dismissed: set[tuple] = {
|
||||
(str(d.provider_id), d.category_id_str, d.category_type)
|
||||
for d in dismissals_result.scalars().all()
|
||||
}
|
||||
|
||||
# Compare last_sync_categories to what's available
|
||||
total = 0
|
||||
for provider in providers:
|
||||
if not provider.last_sync_categories:
|
||||
continue
|
||||
try:
|
||||
last = json.loads(provider.last_sync_categories)
|
||||
except Exception:
|
||||
continue
|
||||
# We can't do a fast count without hitting remote — just return 0 here
|
||||
# Real count comes from get_all_notifications
|
||||
return {"total": total}
|
||||
|
||||
|
||||
class DismissRequest(BaseModel):
|
||||
provider_id: int
|
||||
category_id: str
|
||||
category_type: str
|
||||
|
||||
|
||||
@router.post("/notifications/dismiss")
|
||||
async def dismiss_notification(data: DismissRequest, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
existing = await db.execute(
|
||||
select(NotificationDismissal).where(
|
||||
NotificationDismissal.provider_id == data.provider_id,
|
||||
NotificationDismissal.category_id_str == data.category_id,
|
||||
NotificationDismissal.category_type == data.category_type,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
db.add(NotificationDismissal(
|
||||
provider_id=data.provider_id,
|
||||
category_id_str=data.category_id,
|
||||
category_type=data.category_type,
|
||||
))
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/notifications/dismiss-provider/{provider_id}")
|
||||
async def dismiss_all_for_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Dismiss all pending notifications for a specific provider."""
|
||||
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")
|
||||
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
try:
|
||||
live_raw, vod_raw, series_raw = await asyncio.gather(
|
||||
client.get_live_categories(),
|
||||
client.get_vod_categories(),
|
||||
client.get_series_categories(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to fetch provider categories: {e}")
|
||||
|
||||
db_cats = await db.execute(
|
||||
select(Category.provider_category_id, Category.type)
|
||||
.where(Category.provider_account_id == provider_id)
|
||||
)
|
||||
synced: set[tuple] = {(str(r.provider_category_id), r.type) for r in db_cats}
|
||||
|
||||
dismissed_result = await db.execute(
|
||||
select(NotificationDismissal.category_id_str, NotificationDismissal.category_type)
|
||||
.where(NotificationDismissal.provider_id == provider_id)
|
||||
)
|
||||
already_dismissed: set[tuple] = {(r[0], r[1]) for r in dismissed_result.all()}
|
||||
|
||||
count = 0
|
||||
for raw, ctype in [(live_raw, ContentType.live), (vod_raw, ContentType.movie), (series_raw, ContentType.series)]:
|
||||
if isinstance(raw, Exception):
|
||||
continue
|
||||
for c in (raw if isinstance(raw, list) else []):
|
||||
cat_id = str(c.get("category_id", ""))
|
||||
if not cat_id or (cat_id, ctype) in synced:
|
||||
continue
|
||||
if (cat_id, ctype.value) not in already_dismissed:
|
||||
db.add(NotificationDismissal(
|
||||
provider_id=provider_id,
|
||||
category_id_str=cat_id,
|
||||
category_type=ctype.value,
|
||||
))
|
||||
count += 1
|
||||
|
||||
await db.commit()
|
||||
return {"ok": True, "dismissed": count}
|
||||
|
||||
|
||||
@router.get("/{provider_id}/epg-url")
|
||||
async def get_provider_epg_url(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Returns the XMLTV EPG URL for this provider (auto-registered as EPG source)."""
|
||||
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")
|
||||
url = XtreamClient(provider.base_url, provider.username, provider.password).get_xmltv_url()
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@router.post("/{provider_id}/test")
|
||||
async def test_provider(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
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")
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
ok = False
|
||||
try:
|
||||
info = await client.get_user_info()
|
||||
user_info = info.get("user_info", {})
|
||||
ok = user_info.get("status") == "Active"
|
||||
exp_ts = user_info.get("exp_date")
|
||||
if exp_ts:
|
||||
try:
|
||||
provider.expiry_date = datetime.fromtimestamp(int(exp_ts), tz=timezone.utc)
|
||||
await db.commit()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Provider test failed: {e}")
|
||||
return {"connected": ok}
|
||||
|
||||
|
||||
@router.get("/{provider_id}/sync/categories")
|
||||
async def get_provider_categories(provider_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Fetch available categories from provider without importing — used for the category picker."""
|
||||
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")
|
||||
|
||||
client = XtreamClient(provider.base_url, provider.username, provider.password)
|
||||
|
||||
live_task = asyncio.create_task(client.get_live_categories())
|
||||
vod_task = asyncio.create_task(client.get_vod_categories())
|
||||
series_task = asyncio.create_task(client.get_series_categories())
|
||||
|
||||
live_raw, vod_raw, series_raw = await asyncio.gather(live_task, vod_task, series_task, return_exceptions=True)
|
||||
|
||||
def to_list(raw):
|
||||
if isinstance(raw, Exception):
|
||||
logger.warning(f"Category fetch error: {raw}")
|
||||
return []
|
||||
return [{"id": str(c.get("category_id", "")), "name": c.get("category_name", "")} for c in raw if c.get("category_id")]
|
||||
|
||||
return {
|
||||
"live": to_list(live_raw),
|
||||
"movie": to_list(vod_raw),
|
||||
"series": to_list(series_raw),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{provider_id}/sync/status")
|
||||
async def sync_status(provider_id: int, _=Depends(get_current_admin)):
|
||||
return _sync_status.get(provider_id, {"status": "idle"})
|
||||
|
||||
|
||||
@router.post("/{provider_id}/sync")
|
||||
async def sync_provider(
|
||||
provider_id: int,
|
||||
body: SyncRequest = SyncRequest(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_admin),
|
||||
):
|
||||
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")
|
||||
|
||||
if _sync_status.get(provider_id, {}).get("status") == "running":
|
||||
return {"ok": False, "message": "Sync already in progress", "status": _sync_status[provider_id]}
|
||||
|
||||
_sync_status[provider_id] = {"status": "running", "progress": "Iniciando...", "stats": {}}
|
||||
asyncio.create_task(_run_sync(
|
||||
provider_id,
|
||||
provider.base_url,
|
||||
provider.username,
|
||||
provider.password,
|
||||
selected_live=set(body.live_category_ids) if body.live_category_ids is not None else None,
|
||||
selected_movie=set(body.movie_category_ids) if body.movie_category_ids is not None else None,
|
||||
selected_series=set(body.series_category_ids) if body.series_category_ids is not None else None,
|
||||
))
|
||||
return {"ok": True, "message": "Sync started in background", "status_url": f"/api/admin/providers/{provider_id}/sync/status"}
|
||||
|
||||
|
||||
async def _run_sync(
|
||||
provider_id: int,
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
selected_live: set[str] | None = None,
|
||||
selected_movie: set[str] | None = None,
|
||||
selected_series: set[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Efficient sync: fetches ALL streams in one call per type (not per category).
|
||||
Total HTTP calls: 3 category lists + 3 stream lists = 6 calls regardless of provider size.
|
||||
selected_* = None means import all; set means only import those category IDs.
|
||||
"""
|
||||
client = XtreamClient(base_url, username, password)
|
||||
stats = {
|
||||
"live_categories": 0, "live_channels": 0,
|
||||
"vod_categories": 0, "vod_channels": 0,
|
||||
"series_categories": 0, "series": 0,
|
||||
}
|
||||
|
||||
def update_progress(msg: str):
|
||||
_sync_status[provider_id] = {"status": "running", "progress": msg, "stats": stats.copy()}
|
||||
logger.info(f"[Sync #{provider_id}] {msg}")
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
# ── LIVE ──────────────────────────────────────────────────
|
||||
update_progress("Descargando categorías Live...")
|
||||
try:
|
||||
live_cats = await client.get_live_categories()
|
||||
cat_map_live: dict[str, Category] = {}
|
||||
for cat_data in live_cats:
|
||||
cat_id_str = str(cat_data.get("category_id", ""))
|
||||
if selected_live is not None and cat_id_str not in selected_live:
|
||||
continue
|
||||
cat = await _upsert_category(db, cat_data, ContentType.live, provider_id)
|
||||
cat_map_live[cat_id_str] = cat
|
||||
stats["live_categories"] += 1
|
||||
await db.flush()
|
||||
|
||||
update_progress(f"Descargando canales Live ({stats['live_categories']} cats)...")
|
||||
all_live = await client.get_live_streams()
|
||||
for ch_data in all_live:
|
||||
cat_id_str = str(ch_data.get("category_id", ""))
|
||||
if cat_id_str not in cat_map_live:
|
||||
continue # category not selected or not loaded
|
||||
cat = cat_map_live.get(cat_id_str)
|
||||
await _upsert_channel(db, ch_data, cat, ContentType.live, provider_id, client)
|
||||
stats["live_channels"] += 1
|
||||
if stats["live_channels"] % 500 == 0:
|
||||
update_progress(f"Live: {stats['live_channels']} canales procesados...")
|
||||
await db.flush()
|
||||
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync #{provider_id}] Live error: {e}", exc_info=True)
|
||||
|
||||
# ── VOD ───────────────────────────────────────────────────
|
||||
update_progress("Descargando categorías VOD...")
|
||||
try:
|
||||
vod_cats = await client.get_vod_categories()
|
||||
cat_map_vod: dict[str, Category] = {}
|
||||
for cat_data in vod_cats:
|
||||
cat_id_str = str(cat_data.get("category_id", ""))
|
||||
if selected_movie is not None and cat_id_str not in selected_movie:
|
||||
continue
|
||||
cat = await _upsert_category(db, cat_data, ContentType.movie, provider_id)
|
||||
cat_map_vod[cat_id_str] = cat
|
||||
stats["vod_categories"] += 1
|
||||
await db.flush()
|
||||
|
||||
update_progress(f"Descargando películas VOD ({stats['vod_categories']} cats)...")
|
||||
all_vod = await client.get_vod_streams()
|
||||
for ch_data in all_vod:
|
||||
cat_id_str = str(ch_data.get("category_id", ""))
|
||||
if cat_id_str not in cat_map_vod:
|
||||
continue
|
||||
cat = cat_map_vod.get(cat_id_str)
|
||||
await _upsert_channel(db, ch_data, cat, ContentType.movie, provider_id, client)
|
||||
stats["vod_channels"] += 1
|
||||
if stats["vod_channels"] % 500 == 0:
|
||||
update_progress(f"VOD: {stats['vod_channels']} canales procesados...")
|
||||
await db.flush()
|
||||
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync #{provider_id}] VOD error: {e}", exc_info=True)
|
||||
|
||||
# ── SERIES ────────────────────────────────────────────────
|
||||
update_progress("Descargando categorías Series...")
|
||||
try:
|
||||
series_cats = await client.get_series_categories()
|
||||
cat_map_series: dict[str, Category] = {}
|
||||
for cat_data in series_cats:
|
||||
cat_id_str = str(cat_data.get("category_id", ""))
|
||||
if selected_series is not None and cat_id_str not in selected_series:
|
||||
continue
|
||||
cat = await _upsert_category(db, cat_data, ContentType.series, provider_id)
|
||||
cat_map_series[cat_id_str] = cat
|
||||
stats["series_categories"] += 1
|
||||
await db.flush()
|
||||
|
||||
update_progress(f"Descargando series ({stats['series_categories']} cats)...")
|
||||
all_series = await client.get_series()
|
||||
for s_data in all_series:
|
||||
cat_id_str = str(s_data.get("category_id", ""))
|
||||
if cat_id_str not in cat_map_series:
|
||||
continue
|
||||
cat = cat_map_series.get(cat_id_str)
|
||||
await _upsert_channel(db, s_data, cat, ContentType.series, provider_id, client)
|
||||
stats["series"] += 1
|
||||
if stats["series"] % 500 == 0:
|
||||
update_progress(f"Series: {stats['series']} procesadas...")
|
||||
await db.flush()
|
||||
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync #{provider_id}] Series error: {e}", exc_info=True)
|
||||
|
||||
# ── FINAL COMMIT ──────────────────────────────────────────
|
||||
update_progress("Guardando en base de datos...")
|
||||
result = await db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
if provider:
|
||||
provider.last_sync_at = datetime.now(timezone.utc)
|
||||
provider.last_sync_categories = json.dumps({
|
||||
"live": list(selected_live) if selected_live is not None else None,
|
||||
"movie": list(selected_movie) if selected_movie is not None else None,
|
||||
"series": list(selected_series) if selected_series is not None else None,
|
||||
})
|
||||
await db.commit()
|
||||
|
||||
_sync_status[provider_id] = {"status": "done", "progress": "Sync completado", "stats": stats}
|
||||
logger.info(f"[Sync #{provider_id}] Completed: {stats}")
|
||||
|
||||
# Auto-update EPG source and trigger background refresh
|
||||
async with AsyncSessionLocal() as epg_db:
|
||||
prov_result = await epg_db.execute(select(ProviderAccount).where(ProviderAccount.id == provider_id))
|
||||
prov = prov_result.scalar_one_or_none()
|
||||
if prov:
|
||||
await _sync_epg_source(epg_db, prov)
|
||||
await refresh_epg(epg_db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync #{provider_id}] Fatal error: {e}", exc_info=True)
|
||||
_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")
|
||||
|
||||
# First: check if a category with the same name+type already exists (any provider)
|
||||
# This merges duplicate categories from multiple providers automatically
|
||||
result = await db.execute(
|
||||
select(Category).where(
|
||||
Category.name == cat_name,
|
||||
Category.type == ctype,
|
||||
)
|
||||
)
|
||||
cat = result.scalar_one_or_none()
|
||||
|
||||
if not cat:
|
||||
# Check by provider+id (in case name changed)
|
||||
result2 = await db.execute(
|
||||
select(Category).where(
|
||||
Category.provider_category_id == provider_cat_id,
|
||||
Category.provider_account_id == provider_id,
|
||||
Category.type == ctype,
|
||||
)
|
||||
)
|
||||
cat = result2.scalar_one_or_none()
|
||||
|
||||
if not cat:
|
||||
cat = Category(
|
||||
name=cat_name,
|
||||
type=ctype,
|
||||
provider_category_id=provider_cat_id,
|
||||
provider_account_id=provider_id,
|
||||
)
|
||||
db.add(cat)
|
||||
await db.flush()
|
||||
else:
|
||||
cat.name = cat_name
|
||||
return cat
|
||||
|
||||
|
||||
async def _upsert_channel(
|
||||
db: AsyncSession,
|
||||
ch_data: dict,
|
||||
category: Category | None,
|
||||
ctype: ContentType,
|
||||
provider_id: int,
|
||||
client: XtreamClient,
|
||||
) -> Channel | None:
|
||||
stream_id = str(ch_data.get("stream_id") or ch_data.get("series_id", ""))
|
||||
if not stream_id:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(Channel).where(
|
||||
Channel.stream_id_at_provider == stream_id,
|
||||
Channel.type == ctype,
|
||||
)
|
||||
)
|
||||
channel = result.scalar_one_or_none()
|
||||
|
||||
name = ch_data.get("name") or ch_data.get("title", "Unknown") or "Unknown"
|
||||
tvg_id = ch_data.get("epg_channel_id") or ch_data.get("tvg_id") or None
|
||||
logo = ch_data.get("stream_icon") or ch_data.get("cover") or None
|
||||
# Truncate logo to avoid DB column limit (base64 images from some providers)
|
||||
if logo and len(logo) > 900:
|
||||
logo = None
|
||||
cat_id = category.id if category else None
|
||||
|
||||
if not channel:
|
||||
channel = Channel(
|
||||
name=name,
|
||||
tvg_id=tvg_id,
|
||||
tvg_logo=logo,
|
||||
stream_id_at_provider=stream_id,
|
||||
category_id=cat_id,
|
||||
type=ctype,
|
||||
)
|
||||
db.add(channel)
|
||||
await db.flush()
|
||||
else:
|
||||
channel.name = name
|
||||
channel.tvg_id = tvg_id
|
||||
channel.tvg_logo = logo
|
||||
# Only update category if channel has none yet (first-provider-wins prevents
|
||||
# cross-provider syncs from breaking existing catalog assignments)
|
||||
if cat_id and not channel.category_id:
|
||||
channel.category_id = cat_id
|
||||
|
||||
# Build stream URL
|
||||
container_ext = ch_data.get("container_extension", "ts") or "ts"
|
||||
if ctype == ContentType.live:
|
||||
url = client.build_live_stream_url(stream_id)
|
||||
elif ctype == ContentType.movie:
|
||||
url = client.build_vod_stream_url(stream_id, container_ext)
|
||||
else:
|
||||
url = client.build_series_stream_url(stream_id, container_ext)
|
||||
|
||||
# Upsert provider map
|
||||
result = await db.execute(
|
||||
select(ChannelProviderMap).where(
|
||||
ChannelProviderMap.channel_id == channel.id,
|
||||
ChannelProviderMap.provider_account_id == provider_id,
|
||||
)
|
||||
)
|
||||
pmap = result.scalar_one_or_none()
|
||||
if not pmap:
|
||||
pmap = ChannelProviderMap(
|
||||
channel_id=channel.id,
|
||||
provider_account_id=provider_id,
|
||||
stream_url=url,
|
||||
)
|
||||
db.add(pmap)
|
||||
else:
|
||||
pmap.stream_url = url
|
||||
|
||||
return channel
|
||||
Reference in New Issue
Block a user