cccdf9f137
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.
834 lines
33 KiB
Python
834 lines
33 KiB
Python
"""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
|